Let an administrator change the public address after setup

Setup asked for it once and nothing could answer it again. An install
restored from another machine's backup therefore kept the old machine's
address: browsers were sent there for live updates and the stream
indicator stayed red, with an environment variable in the compose file
as the only remedy.

Admin → Address edits the same value setup writes. The change is saved
to the generated file and takes a restart to apply. Making it live was
left out on purpose: the entrypoint exports that file into every
container's environment at start, and telling a stale export from a
deliberate pin needs more machinery than a restart costs. The page shows
the address in use beside the saved one whenever they differ, and offers
the web container's restart after every save.

One thing fell out. A hub address on file that is only the previous
public address with the hub's path on it is dropped on save, so it is
derived from the new address again; a hub address of its own is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-02 10:25:56 +02:00
1 parent 0507c1e716
commit be3b27cf32
18 files changed
+546 -7

No files matched your search

BIN
View File
Binary file not shown.
+2 -2
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/configuration.md sha1:c07a21499b2262504fc78db0a38abc2686e150c5 -->
<!-- translated-from: install/configuration.md sha1:4891178bed3970f53bdec5785209ab33bbd47f59 -->
# Konfigurationsreferenz
Jede Umgebungsvariable, die plMail liest, was sie bewirkt, welchen Vorgabewert sie hat und was
@@ -44,7 +44,7 @@ Das sind die Variablen aus `.env`, in der Reihenfolge, in der sie dort stehen.
| `APP_SHARE_DIR` | Nichts. Kein Code in diesem Repository liest sie. | `var/share` | Nein | Nichts. Sie bleibt bestehen, weil das Entfernen einer Variablen eine Änderung an `.env` wäre; behandle sie als wirkungslos. |
| `APP_SECRETS_FILE` | Pfad zur Datei der erzeugten Geheimnisse, relativ zum Projektstammverzeichnis, sofern er nicht mit `/` beginnt. | `var/secrets/generated.env` | Ja | Zeigt ein Dienst dorthin, wo die anderen nicht hinsehen, erzeugt er seinen eigenen Verschlüsselungsschlüssel. `EncryptionKeyProbe` bemerkt das beim Start und verweigert den Start des Servers. |
| `APP_STORAGE_DIR` | Wurzel für alles, was plMail als Datei schreibt, relativ zum Projektstammverzeichnis: `attachments/`, `raw/`, `uploads/` (Avatare liegen unter `uploads/avatars/`). | `var` | Ja | Anhangpfade werden relativ zum Projektstammverzeichnis in der Datenbank gespeichert; eine Änderung auf einer Installation, in der bereits Mail liegt, macht daher jede vorhandene Datei unauffindbar. |
| `APP_PUBLIC_URL` | Die Adresse, unter der plMail von außen erreicht wird — die, die Google und Microsoft zurückrufen. | leer; wird auf dem Einrichtungsbildschirm abgefragt und in die erzeugte Datei geschrieben | Für Push: ja | Leer, `http://` oder ein Loopback-Host, und Kalender-Push sowie Graph-Mail-Push registrieren sich überhaupt nicht. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
| `APP_PUBLIC_URL` | Die Adresse, unter der plMail von außen erreicht wird — die, die Google und Microsoft zurückrufen. | leer; wird auf dem Einrichtungsbildschirm abgefragt und in die erzeugte Datei geschrieben. Später unter Admin → Adresse änderbar, was einen Neustart braucht | Für Push: ja | Leer, `http://` oder ein Loopback-Host, und Kalender-Push sowie Graph-Mail-Push registrieren sich überhaupt nicht. Siehe [Hinter einem Reverse-Proxy](reverse-proxy.md). |
| `APP_DEFAULT_TIMEZONE` | Die Uhr, die jemandem angezeigt wird, der in den Einstellungen nie eine gewählt hat. | `Europe/Berlin` (zugleich der Parameter `app.fallback_timezone`, der greift, wenn die Variable fehlt) | Nein | Eine Installation mit falscher Zone zeigt allen diesen Personen Zeiten, die um ihren eigenen Versatz plausibel falsch sind. Zeitstempel werden in UTC gespeichert und nur zur Anzeige umgerechnet, eine Korrektur rendert also neu, statt umzuschreiben. Nicht zu verwechseln mit der PHP-Zeitzone des Containers, die in `frankenphp/conf.d/10-app.ini` fest auf `UTC` steht. |
| `DEFAULT_URI` | Basis-URI, die der Router beim Erzeugen von URLs außerhalb einer HTTP-Anfrage verwendet. | `http://localhost` | Nein | Links in Mail, die aus einem Konsolenbefehl entsteht, zeigen auf localhost. Push-Rückrufe verwenden das nicht — sie verwenden `APP_PUBLIC_URL`. |
| `DATABASE_URL` | Doctrine-Verbindung. | `postgresql://app@database:5432/app?serverVersion=18&charset=utf8` — bewusst ohne Zugangsdaten | Ja, aber zusammengesetzt | Eine DSN **ohne** Passwort wird als "niemand hat eine Datenbank konfiguriert" gewertet, und das erzeugte `POSTGRES_PASSWORD` wird eingesetzt. Eine DSN mit Passwort wird als Absicht gewertet und unangetastet gelassen — ein falsches Passwort hier wird also wörtlich verwendet, und Postgres lehnt die Verbindung ab. Sie ist nicht leer, weil Doctrine den Treiber aus dem Schema liest und eine leere DSN das Aufwärmen des Caches beim Image-Build mit "could not find driver" scheitern lässt. |
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: install/platforms.md sha1:727c18329d917d9982252fd27e182991b46a96e2 -->
<!-- translated-from: install/platforms.md sha1:a5d607be3fca4c06dcb8499899db2014cb726ef3 -->
# Plattform-Hinweise
plMail führt überall dieselben Container aus, deshalb geht es auf dieser Seite fast nur um die
+1 -1
View File
@@ -40,7 +40,7 @@ These are the variables in `.env`, in the order they appear there.
| `APP_SHARE_DIR` | Nothing. No code in this repository reads it. | `var/share` | No | Nothing. Left in place because removing a variable is a change to `.env`; treat it as inert. |
| `APP_SECRETS_FILE` | Path to the generated secrets file, relative to the project root unless it starts with `/`. | `var/secrets/generated.env` | Yes | Point one service somewhere the others cannot see and it mints its own encryption key. `EncryptionKeyProbe` catches that at boot and refuses to start the server. |
| `APP_STORAGE_DIR` | Root for everything plMail writes as a file, relative to the project root: `attachments/`, `raw/`, `uploads/` (avatars live under `uploads/avatars/`). | `var` | Yes | Attachment paths are stored in the database relative to the project root, so changing this on an install that already holds mail orphans every existing file. |
| `APP_PUBLIC_URL` | The address plMail is reached at from outside — what Google and Microsoft call back to. | blank; asked for on the setup screen and written to the generated file | For push, yes | Blank, `http://`, or a loopback host and calendar push and Graph mail push refuse to register at all. See [Behind a reverse proxy](reverse-proxy.md). |
| `APP_PUBLIC_URL` | The address plMail is reached at from outside — what Google and Microsoft call back to. | blank; asked for on the setup screen and written to the generated file. Changed afterwards under Admin → Address, which takes a restart to apply | For push, yes | Blank, `http://`, or a loopback host and calendar push and Graph mail push refuse to register at all. See [Behind a reverse proxy](reverse-proxy.md). |
| `APP_DEFAULT_TIMEZONE` | The clock a user who has never picked one in Settings is shown. | `Europe/Berlin` (also the `app.fallback_timezone` parameter, used when the variable is absent) | No | An install left on the wrong zone shows every such user times that are plausibly wrong by their own offset. Timestamps are stored in UTC and only converted for display, so fixing it re-renders rather than rewrites. Not the container's PHP timezone, which is pinned to `UTC` in `frankenphp/conf.d/10-app.ini`. |
| `DEFAULT_URI` | Base URI the router uses when generating URLs outside an HTTP request. | `http://localhost` | No | Links in mail generated from a console command point at localhost. Push callbacks do not use this — they use `APP_PUBLIC_URL`. |
| `DATABASE_URL` | Doctrine connection. | `postgresql://app@database:5432/app?serverVersion=18&charset=utf8` — deliberately credential-less | Yes, but assembled | A DSN that carries **no** password is treated as "nobody configured a database", and the generated `POSTGRES_PASSWORD` is spliced in. A DSN that carries one is taken as intent and left alone — so a wrong password here is used verbatim and Postgres refuses the connection. It is not blank because Doctrine reads the driver out of the scheme, and a blank DSN fails the image build's cache warmup with "could not find driver". |
BIN
View File
Binary file not shown.
@@ -32,7 +32,7 @@ final class AdminDashboardController extends AbstractController
{
use ChecksCsrf;
private const array SECTIONS = ['system', 'database', 'logs', 'insight-reports', 'integrations', 'push', 'ai', 'users', 'backup', 'updates', 'reset'];
private const array SECTIONS = ['system', 'database', 'logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'users', 'backup', 'updates', 'reset'];
private const int LOGS_PER_PAGE = 100;
/**
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Form\Admin\PublicUrlType;
use App\Service\Setup\PublicUrlSetting;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Admin → Address: where plMail is reached from outside, after setup.
*
* The setup screen asks for this once and there was no way to answer it again.
* That left an install restored from another machine's backup telling every
* browser to subscribe to live updates at the old machine's address, with the
* only remedy an environment variable in the compose file.
*
* One action for GET and POST and a Turbo Frame of its own, as Admin → Push
* does it and for the reasons recorded there.
*
* Saving does not restart anything. The frame says a restart is needed and
* offers the web container's, which is the one that can be done from here; the
* rest of the stack is the operator's to restart, as it is after a restored
* backup. PublicUrlSetting records why the change is not applied live.
*/
#[Route('/admin/address', name: 'app_admin_address_')]
#[IsGranted('ROLE_ADMIN')]
final class PublicAddressController extends AbstractController
{
public function __construct(
private readonly PublicUrlSetting $publicUrl,
) {}
#[Route('', name: 'settings', methods: ['GET', 'POST'])]
public function settings(Request $request): Response
{
// Prefilled with what is on file rather than what is in force: the
// form edits the file, and after a save that is still waiting for its
// restart the two differ.
$form = $this->createForm(PublicUrlType::class, [
'publicUrl' => $this->publicUrl->stored() ?? $this->publicUrl->current(),
], [
// Explicit for the reason PushSettingsController gives: inside a
// Turbo Frame a form with no action posts to the document URL.
'action' => $this->generateUrl('app_admin_address_settings'),
]);
$form->handleRequest($request);
$saved = false;
if (true === $form->isSubmitted() && true === $form->isValid()) {
$this->publicUrl->save((string) $form->get('publicUrl')->getData());
$saved = true;
}
return $this->render('admin/address/_frame.html.twig', [
'form' => $form,
'saved' => $saved,
'current' => $this->publicUrl->current(),
'stored' => $this->publicUrl->stored(),
]);
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Form\PasswordManagerIgnore;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Url;
/**
* The public address, on its own — the field the setup screen asks for among
* the first administrator's details, for the administrator who needs to change
* it afterwards.
*
* The same rules as there, deliberately: FirstAdminType explains why the TLD is
* not required, and an address setup accepted must not be one this form then
* refuses to save back.
*/
final class PublicUrlType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('publicUrl', UrlType::class, [
'label' => 'admin.address.field.public_url',
'help' => 'admin.address.field.public_url_help',
'default_protocol' => null,
'constraints' => [new NotBlank(), new Url(requireTld: false)],
// A server address, not an account of any kind.
'attr' => PasswordManagerIgnore::ATTR,
]);
}
}
+45 -1
View File
@@ -21,9 +21,23 @@ use Throwable;
* Stored in the generated-config file, the one place a running container can
* write that every other service reads. An APP_PUBLIC_URL supplied through the
* environment still wins, so a deployment that sets it is untouched.
*
* Changeable after setup, from Admin → Address: an install gets a domain, moves
* behind a different proxy, or is restored from a backup taken somewhere else,
* and the address in that backup is the old machine's. A change takes a restart
* to reach processes that are already running. Making it live was considered
* and left out — the entrypoint exports this file into every container's
* environment at start, and telling a stale export from a deliberate pin needs
* more machinery than a restart costs.
*/
final readonly class PublicUrlSetting
{
/**
* Where the hub answers on the app's own origin — the suffix
* config/bootstrap_generated_secrets.php derives MERCURE_PUBLIC_URL with.
*/
private const string HUB_PATH = '/.well-known/mercure';
public function __construct(
private GeneratedSecretsFile $config,
private WorkerRestartSignal $workerRestart,
@@ -33,7 +47,21 @@ final readonly class PublicUrlSetting
public function save(string $url): void
{
$this->config->set('APP_PUBLIC_URL', rtrim(trim($url), '/'));
$url = rtrim(trim($url), '/');
// A hub address on file that is only the previous public address with
// the hub's path on it was never a decision of its own — a restored
// backup carries one. Left in place it keeps browsers subscribing at
// the address that was just replaced, and live updates stay dead while
// everything else has moved. One that points anywhere else is somebody's
// separate hub, and stays.
$previous = $this->stored();
if (null !== $previous && $previous.self::HUB_PATH === trim($this->config->read()['MERCURE_PUBLIC_URL'] ?? '')) {
$this->config->remove(['MERCURE_PUBLIC_URL']);
}
$this->config->set('APP_PUBLIC_URL', $url);
// The web process picks the new value up on the next request; the
// workers are long-running and would otherwise hold the old one until
@@ -72,6 +100,22 @@ final readonly class PublicUrlSetting
return '' === $stored ? null : rtrim($stored, '/');
}
/**
* What is on file, whatever the running processes were started with.
*
* Differs from current() in exactly two situations, and Admin → Address
* shows both values because it cannot tell them apart: a saved address
* still waiting for its restart, since the entrypoint exports the file into
* the environment once, at container start; or an APP_PUBLIC_URL set in the
* compose file, which wins over the file for good.
*/
public function stored(): ?string
{
$stored = trim($this->config->read()['APP_PUBLIC_URL'] ?? '');
return '' === $stored ? null : rtrim($stored, '/');
}
/**
* The address this request arrived on, as the best guess to offer during
* setup. Correct whenever plMail is reached the same way its users reach
BIN
View File
Binary file not shown.
+112
View File
@@ -0,0 +1,112 @@
{% import '_partials/_card_chrome.html.twig' as chrome %}
{#
admin/address/_frame.html.twig — Admin → Address.
Accepts:
form — App\Form\Admin\PublicUrlType
saved — true immediately after a successful save
current — the address the running processes use, or null when there is none
stored — the address on file, or null when nothing was ever saved
**Two addresses, shown side by side whenever they differ.** `current` is what
this process was started with and `stored` is what the next start will read.
They part ways after a save that has not been restarted yet, and also when
APP_PUBLIC_URL is set in the compose file, which wins over the file for good.
The page cannot tell those apart, so it names both and lets the administrator
recognise their own situation rather than guessing at one.
The restart button is the System panel's own, posting to the same action with
the same token. It restarts the web container only, which is what brings live
updates to the new address; the note beside it says what it does not reach.
No auto-refresh wraps this frame — it is configuration, and a poll would empty
the field mid-word.
#}
{# Layout classes belong on the placeholder in admin/index.html.twig, not
here: Turbo transplants this frame's CHILDREN into the frame already on
the page and never copies its attributes, so a class written here reaches
nothing. See tests/Infrastructure/Templating/TurboFrameClassesTest.php. #}
<turbo-frame id="admin-address">
{% set pending = stored is not null and stored != current %}
<div class="{{ chrome.shell() }}">
<div class="flex items-center gap-2.5 {{ chrome.band() }}">
<i class="fa-solid fa-globe text-xs text-ink-faint" aria-hidden="true"></i>
<h2 class="text-sm font-semibold text-ink">{{ 'admin.address.heading'|trans }}</h2>
</div>
<div class="{{ chrome.body() }} space-y-4">
{% if saved %}
<div role="status" class="flex items-start gap-2.5 rounded-lg bg-emerald-500/10 px-3.5 py-3 text-sm text-emerald-500">
<i class="fa-solid fa-circle-check mt-0.5 shrink-0" aria-hidden="true"></i>
<p class="leading-snug">{{ 'admin.address.saved'|trans }}</p>
</div>
{% endif %}
<p class="text-sm text-ink-soft leading-relaxed">{{ 'admin.address.intro'|trans }}</p>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt class="text-ink-faint">{{ 'admin.address.in_use'|trans }}</dt>
<dd class="font-mono text-ink-soft break-all">{{ current ?? 'admin.address.none'|trans }}</dd>
{% if pending %}
<dt class="text-ink-faint">{{ 'admin.address.on_file'|trans }}</dt>
<dd class="font-mono text-ink-soft break-all">{{ stored }}</dd>
{% endif %}
</dl>
{% if pending %}
<div role="status" class="rounded-lg bg-amber-500/10 px-3.5 py-3 text-sm text-amber-500">
<p class="leading-snug">{{ 'admin.address.pending'|trans }}</p>
</div>
{% endif %}
{# After a save as well as while one is pending. The two are not
the same moment: a process that reads the file on every call
shows the new address at once, and the hub address the layout
hands to browsers is still the one this kernel booted with. #}
{% if saved or pending %}
<div>
<p class="text-xs text-ink-faint leading-relaxed">{{ 'admin.address.restart_help'|trans }}</p>
{# data-turbo="false", as on the System panel: the answer
is a full page that outlives the process serving it,
not something to swap into this frame. #}
<form method="post" action="{{ path('app_admin_system_restart_app') }}" data-turbo="false" class="mt-2">
<input type="hidden" name="_token" value="{{ csrf_token('admin_restart_app') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium btn-quiet hover:bg-hover transition-colors cursor-pointer">
<i class="fa-solid fa-power-off" aria-hidden="true"></i>
{{ 'admin.maintenance.restart_app'|trans }}
</button>
</form>
</div>
{% endif %}
{{ form_start(form, { attr: { 'data-turbo-frame': 'admin-address' } }) }}
<div class="space-y-4">
{{ form_widget(form) }}
<div class="flex justify-end">
<button type="submit"
class="inline-flex items-center gap-2 h-9 px-4 rounded-lg text-sm font-medium
text-accent-ink bg-accent hover:bg-accent-strong transition-colors cursor-pointer">
<i class="fa-solid fa-floppy-disk text-xs" aria-hidden="true"></i>
{{ 'admin.address.save'|trans }}
</button>
</div>
</div>
{{ form_end(form) }}
</div>
</div>
{# px-4, the house inset: this note sits outside the card and has to
begin where the card's own content begins, not at its own figure. #}
<p class="px-4 text-xs text-ink-faint leading-relaxed">
<i class="fa-solid fa-circle-info mr-1" aria-hidden="true"></i>
{{ 'admin.address.follow_up'|trans }}
</p>
</turbo-frame>
+23 -1
View File
@@ -77,7 +77,7 @@
</a>
{% endif %}
{% if section not in ['logs', 'insight-reports', 'integrations', 'push', 'ai', 'backup', 'updates', 'reset'] %}
{% if section not in ['logs', 'insight-reports', 'address', 'integrations', 'push', 'ai', 'backup', 'updates', 'reset'] %}
<span class="ml-auto text-xs text-ink-faint">{{ 'admin.refresh_hint'|trans }}</span>
{% endif %}
</div>
@@ -131,6 +131,14 @@
aria-label="{{ 'admin.insight_reports.pending'|trans({ '%count%': pendingInsightReports }) }}">{{ pendingInsightReports }}</span>
{% endif %}
</a>
{# Before integrations, because those are
configured against it: the redirect address
each provider is given is built from this. #}
<a href="{{ path('app_admin_dashboard', { section: 'address' }) }}"
class="{{ section == 'address' ? navActive : navInactive }}">
<i class="fa-solid fa-globe w-4 {{ section == 'address' ? 'text-ink-faint' : '' }}" aria-hidden="true"></i>
{{ 'admin.nav.address'|trans }}
</a>
<a href="{{ path('app_admin_dashboard', { section: 'integrations' }) }}"
class="{{ section == 'integrations' ? navActive : navInactive }}">
<i class="fa-solid fa-plug w-4 {{ section == 'integrations' ? 'text-ink-faint' : '' }}" aria-hidden="true"></i>
@@ -240,6 +248,20 @@
</div>
</turbo-frame>
{% elseif section == 'address' %}
{# No auto-refresh: configuration, and
a poll would empty the field
mid-word. #}
<turbo-frame id="admin-address"
class="block space-y-3"
src="{{ path('app_admin_address_settings') }}"
loading="lazy">
<div class="py-10 text-center text-sm text-ink-faint">
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
</div>
</turbo-frame>
{% elseif section == 'integrations' %}
{# No auto-refresh: this is configuration, not
BIN
View File
Binary file not shown.
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin;
use App\Entity\User\User;
use App\Infrastructure\Setup\GeneratedSecretsFile;
use App\Repository\User\UserRepository;
use App\Service\Monitoring\WorkerRestartSignal;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* The public address can be changed after setup, and a change is written to the
* file rather than only acknowledged.
*
* Setup asked for it once and nothing could answer it again, so an install
* restored from another machine's backup kept sending browsers to that
* machine's address for live updates. The claim worth pinning is the write: a
* form that re-renders with the new value in its field and a green notice looks
* finished whether or not anything reached the file the next start reads.
*/
final class PublicAddressControllerTest extends WebTestCase
{
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
private KernelBrowser $client;
private Connection $connection;
private GeneratedSecretsFile $config;
protected function setUp(): void
{
$this->client = static::createClient();
$container = static::getContainer();
$this->connection = $container->get(Connection::class);
$this->config = $container->get(GeneratedSecretsFile::class);
$admin = $container->get(UserRepository::class)->findOneBy(['email' => self::ADMIN_EMAIL]);
if (false === $admin instanceof User) {
self::markTestSkipped('run `app:test:seed-user --admin` first');
}
$this->client->loginUser($admin);
// Outside the transaction, for the reason InstallEmptyInstallTest
// gives: saving nudges the workers through a cache pool that creates
// its table on first write.
$container->get(WorkerRestartSignal::class)->request();
$this->connection->beginTransaction();
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
// The file is shared by the whole suite, and an address left in it
// would be the public URL of every test that runs afterwards.
$this->config->remove(['APP_PUBLIC_URL']);
parent::tearDown();
}
public function testASavedAddressReachesTheFile(): void
{
$crawler = $this->client->request('GET', '/admin/address');
self::assertResponseIsSuccessful();
$this->client->submit($crawler->filter('form[name="public_url"]')->form([
'public_url[publicUrl]' => 'https://mail.example.test/',
]));
self::assertResponseIsSuccessful();
self::assertSame('https://mail.example.test', $this->config->read()['APP_PUBLIC_URL'] ?? null);
self::assertSelectorExists('turbo-frame#admin-address [role="status"]');
// Offered straight away, not only once the two addresses are seen to
// differ: the hub address browsers are handed is fixed when the kernel
// boots, so a save is never complete without it.
self::assertSelectorExists('turbo-frame#admin-address form[action="/admin/system/restart-app"]');
}
public function testSomethingThatIsNotAnAddressIsRefusedRatherThanStored(): void
{
$crawler = $this->client->request('GET', '/admin/address');
$this->client->submit($crawler->filter('form[name="public_url"]')->form([
'public_url[publicUrl]' => 'not an address',
]));
self::assertArrayNotHasKey('APP_PUBLIC_URL', $this->config->read());
}
public function testTheSectionIsReachableFromTheAdminNavigation(): void
{
$crawler = $this->client->request('GET', '/admin?section=address');
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter('turbo-frame#admin-address[src="/admin/address"]'));
}
}
@@ -99,6 +99,53 @@ final class PublicUrlSettingTest extends TestCase
self::assertNull($this->setting()->current());
}
public function testASavedAddressIsOnFileWhileTheOldOneIsStillInForce(): void
{
// What a running container looks like after a change in Admin →
// Address: the entrypoint exported the old address at start, and the
// environment wins until the next one.
file_put_contents($this->secretsFile, "APP_PUBLIC_URL=https://old.example.com\n");
$_SERVER['APP_PUBLIC_URL'] = 'https://old.example.com';
$setting = $this->setting();
$setting->save('https://new.example.com/');
self::assertSame('https://old.example.com', $setting->current(), 'the running process keeps what it started with');
self::assertSame('https://new.example.com', $setting->stored(), 'the next start reads the new one');
}
public function testAHubAddressThatOnlyFollowedTheOldAddressIsDropped(): void
{
// A restored backup carries one. Left on file it would keep every
// browser subscribing at the address that was just replaced.
file_put_contents(
$this->secretsFile,
"APP_PUBLIC_URL=https://old.example.com\nMERCURE_PUBLIC_URL=https://old.example.com/.well-known/mercure\nMERCURE_JWT_SECRET=kept\n",
);
$this->setting()->save('https://new.example.com');
$stored = (new GeneratedSecretsFile($this->secretsFile))->read();
self::assertArrayNotHasKey('MERCURE_PUBLIC_URL', $stored, 'so that it is derived from the new address again');
self::assertSame('kept', $stored['MERCURE_JWT_SECRET'] ?? null, 'nothing else on file is touched');
}
public function testAHubAddressOfItsOwnSurvivesAChange(): void
{
file_put_contents(
$this->secretsFile,
"APP_PUBLIC_URL=https://old.example.com\nMERCURE_PUBLIC_URL=https://hub.example.net/.well-known/mercure\n",
);
$this->setting()->save('https://new.example.com');
self::assertSame(
'https://hub.example.net/.well-known/mercure',
(new GeneratedSecretsFile($this->secretsFile))->read()['MERCURE_PUBLIC_URL'] ?? null,
);
}
private function setting(): PublicUrlSetting
{
return new PublicUrlSetting(
+36
View File
@@ -58,6 +58,41 @@ admin:
last_activity: 'Letzte Aktivität'
messages: 'Nachrichten'
threads: 'Konversationen'
address:
field:
public_url: Öffentliche Adresse
public_url_help: >-
Unter dieser Adresse ist plMail von außen erreichbar, mit http:// oder
https:// davor und ohne Pfad. Gmail und Outlook schicken
Push-Benachrichtigungen dorthin zurück, dafür muss sie aus dem Internet
über https erreichbar sein.
follow_up: >-
Was woanders eingetragen ist, zieht nicht von selbst mit: die
Weiterleitungsadressen in den Konsolen von Google und Microsoft, ein
Pub/Sub-Push-Endpunkt für Gmail und die Serveradresse in Mail- und
Kalender-Apps. Browser-Benachrichtigungen musst du auf jedem Gerät neu
einschalten.
heading: Öffentliche Adresse
in_use: Derzeit verwendet
intro: >-
Aus dieser Adresse baut plMail seine eigenen Links: wohin sich Browser für
Live-Aktualisierungen verbinden und wohin Google und Microsoft
zurückrufen sollen. Ändere sie, wenn die Installation auf eine andere
Domain, einen anderen Port oder hinter einen anderen Proxy zieht, oder
nachdem du eine Sicherung von einer anderen Maschine eingespielt hast.
none: keine
on_file: Gespeichert
pending: >-
Die gespeicherte Adresse ist nicht die verwendete. Eine Änderung greift,
sobald die Container neu starten. Weichen die beiden nach einem Neustart
immer noch voneinander ab, ist APP_PUBLIC_URL in der Compose-Datei
gesetzt, und das hat Vorrang.
restart_help: >-
Für Live-Aktualisierungen reicht es, plMail hier neu zu starten. Starte
zusätzlich den ganzen Stack neu, damit die Worker Push unter der neuen
Adresse anmelden.
save: Adresse speichern
saved: 'Gespeichert — greift beim nächsten Neustart.'
ago: 'her'
ai:
backfill:
@@ -653,6 +688,7 @@ admin:
page: 'Seite'
refresh: 'Aktualisieren'
nav:
address: Adresse
ai: KI
backup: Sicherung
database: Datenbank
+32
View File
@@ -58,6 +58,37 @@ admin:
last_activity: 'Last activity'
messages: 'Messages'
threads: 'Threads'
address:
field:
public_url: Public address
public_url_help: >-
Where people reach plMail from outside, with http:// or https:// in
front and no path. Gmail and Outlook send push notifications back to
it, so for those it has to be reachable from the internet over https.
follow_up: >-
Things registered elsewhere do not follow by themselves: the redirect
addresses in the Google and Microsoft consoles, a Gmail Pub/Sub push
endpoint, and the server address in mail and calendar apps. Browser
notifications have to be switched on again on each device.
heading: Public address
in_use: In use now
intro: >-
The address plMail builds its own links from: where browsers connect for
live updates, and where Google and Microsoft are told to call back.
Change it when the install moves to another domain, port or proxy, or
after restoring a backup taken on a different machine.
none: none
on_file: Saved
pending: >-
The saved address is not the one in use. A change takes effect when the
containers start again. If the two still differ after a restart,
APP_PUBLIC_URL is set in the compose file, and that setting wins over
this one.
restart_help: >-
Restarting plMail here is enough for live updates. Restart the whole
stack as well, so the workers register push at the new address.
save: Save address
saved: 'Saved — it takes effect at the next restart.'
ago: 'ago'
ai:
backfill:
@@ -646,6 +677,7 @@ admin:
page: 'Page'
refresh: 'Refresh'
nav:
address: Address
ai: AI
backup: Backup
database: Database
+33
View File
@@ -58,6 +58,38 @@ admin:
last_activity: 'Last sighted'
messages: 'Bottles'
threads: 'Yarns'
address:
field:
public_url: Home port
public_url_help: >-
Where folk hail plMail from the open sea, with http:// or https://
afore it an' no path. Gmail an' Outlook send their push signals
back to it, so for those she must be reachable from the wide
internet over https.
follow_up: >-
What be written down elsewhere follows not by itself: the redirect
addresses in the Google an' Microsoft consoles, a Gmail Pub/Sub push
endpoint, an' the server address in mail an' almanac apps. Browser
signals must be hoisted anew on every device.
heading: Home port
in_use: Flyin' now
intro: >-
The address plMail builds her own links from: where browsers moor for
live tidings, an' where Google an' Microsoft be told to call back.
Change her when the ship moves to another domain, port or proxy, or
after restorin' ship's papers taken aboard another vessel.
none: none
on_file: Logged
pending: >-
The logged address be not the one flyin'. A change takes hold when
the containers start anew. If the two still differ after a rousin',
APP_PUBLIC_URL be set in the compose file, an' that order outranks
this one.
restart_help: >-
Rousin' plMail here be enough for live tidings. Rouse the whole stack
besides, so the hands register push at the new address.
save: Log the address
saved: 'Logged — she takes hold at the next rousin''.'
ago: 'ago'
ai:
backfill:
@@ -583,6 +615,7 @@ admin:
page: 'Page'
refresh: 'Refresh'
nav:
address: Home port
ai: Oracle
backup: "Ship's papers"
database: Database