plMail

Behind a reverse proxy

Anything reachable from outside your own network wants a real certificate in front of it, and plMail is written on the assumption that terminating TLS elsewhere is the normal deployment. Three settings decide whether that works: APP_PUBLIC_URL, TRUSTED_PROXIES and MERCURE_PUBLIC_URL. Each of them fails quietly when it is wrong, which is the reason for this page.

Defaults and precedence for everything named here are in the configuration reference.

The shape of it

Your proxy holds the certificate and forwards to the php container over plain HTTP:

services:
  php:
    environment:
      SERVER_NAME: ":80"        # FrankenPHP serves plain HTTP; no certificate here

SERVER_NAME defaults to localhost, php:80, and Caddy inside the container decides from that name whether to terminate TLS itself. :80 tells it not to. Publish only the HTTP port then — HTTP_PORT maps the host port to container port 80 — and let the proxy reach it. truenas.compose.yaml does exactly this and publishes 30080.

The other half is telling the app who it is:

APP_PUBLIC_URL=https://mail.example.com
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
MERCURE_PUBLIC_URL=https://mail.example.com/.well-known/mercure

The failure mode is leaving SERVER_NAME at its default behind a proxy. Caddy then tries to serve localhost while the proxy addresses it by container name or IP, and the request does not match a site block.

APP_PUBLIC_URL

The address plMail is reached at from outside. It is not a cosmetic setting: it is what Google and Microsoft are told to call back to.

It has no default and cannot be inferred at the moment it is needed, because the process that registers a push subscription is a long-running worker or a scheduled console command — neither has a request to read a hostname from. So the setup screen asks for it and writes it into var/secrets/generated.env, the one place a running container can write that every other service reads. Anything supplied through the environment wins over that stored value.

It is resolved per call rather than injected once, which is what lets a worker that booted before setup see the address an administrator saved afterwards. Saving it also signals every worker to restart so they pick it up immediately instead of at their next hourly recycle.

Two hard requirements come from the providers, checked locally before a registration is attempted so that the log says which setting is missing rather than repeating a remote validation error:

Fail either and calendar push and Microsoft Graph mail push do not register. That is not an error condition — an install with no publicly reachable address simply polls instead, and polling is unchanged by any of it: app:mail:sync runs every 15 minutes and app:calendar:sync --stale every 15 minutes offset off the quarter hour. Push only makes mail and calendar changes arrive first rather than late.

The failure mode is a public URL with a trailing path or a trailing slash. It is trimmed of a trailing / when stored, and webhook URLs are built by appending a generated route path to it — so anything else in it ends up in the middle of a callback address the provider will happily register and never successfully call.

TRUSTED_PROXIES

Symfony trusts X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Port, but only from the addresses listed here. The committed default covers loopback and the three RFC1918 ranges, which is right for a proxy on the same Docker network or the same LAN.

Get it wrong in the narrow direction — the proxy's address is not in the list — and Symfony believes it is serving plain HTTP to whatever address the proxy connects from. Four things follow, and none of them announces itself:

Get it wrong in the wide direction — trusting everything — and a client can forge X-Forwarded-For and present itself as any address it likes, which defeats the per-address rate limits and puts a chosen value into your logs.

The failure mode is diagnosing this as an OAuth problem. A redirect-URI mismatch names the URI, and the http:// in it is the entire clue.

Mercure's public URL

The hub is proxied on the app's own origin: frankenphp/Caddyfile routes /.well-known/mercure* to the mercure container, so the browser reaches the hub same-origin, the subscriber cookie is a first-party cookie, and no CORS configuration exists anywhere.

Two variables, and they point in opposite directions:

config/bootstrap_generated_secrets.php derives the second from APP_PUBLIC_URL so there is one less thing to fill in — but only when MERCURE_PUBLIC_URL is unset or empty, and the stock compose.yaml sets it unconditionally to https://localhost/.well-known/mercure. On a proxied install, set it explicitly. truenas.compose.yaml leaves its own value blank precisely so the derivation can happen.

The scheme matters as well as the host: Symfony derives the subscriber cookie's secure flag from this URL, so an https value on a plain-HTTP install mints a cookie the browser will not send back.

If your hub genuinely lives on another domain, the subscriber cookie cannot be issued at all — MercureCookieSubscriber logs that once at info and the page still renders. Live updates are simply unavailable, which is the honest outcome rather than a 500.

The failure mode is an app that works perfectly except that nothing ever updates on its own. No error is shown, because the page rendered fine; the stream just never opens. The topbar's connection indicator is where this becomes visible.

Proxy configuration itself

Caddy sets the forwarded headers automatically:

mail.example.com {
	reverse_proxy plmail:80
}

nginx needs them spelled out, and needs one more thing — plMail serves two long-lived streaming responses, and a proxy that buffers or times out will break both. The Mercure stream is Server-Sent Events and a buffering proxy holds the events instead of passing them through. The assistant's streaming endpoints are worse served by the default read timeout: a thread summary sends nothing at all while the model reads the conversation, and on a long thread that silence is minutes.

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
}

location /.well-known/mercure {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering off;
    proxy_read_timeout 24h;
}

# The assistant streams its answers a token at a time, and says nothing at all
# while the model is reading. nginx's default read timeout is 60 seconds, which
# is shorter than a summary of a long conversation legitimately takes.
location ~ ^/mail/(thread/\d+/summary|compose-assist) {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering off;
    proxy_read_timeout 20m;
}

The failure mode is a summary that fails after a wait, with nothing in the log to say why. The proxy closes the connection during the silence; plMail notices only that the reader has gone, which is indistinguishable from somebody navigating away. It shows as "The summary could not be written" on the card and a cancelled call in the assistant's performance panel. Since 0.2, the server log carries Thread summary abandoned before it finished with the elapsed seconds — and a connection that dies at the same round number every time is a proxy timeout rather than a reader.

plMail sends X-Accel-Buffering: no on these responses, which nginx and several hosted proxies honour and Caddy ignores because Caddy does not buffer in the first place. It does not help with a read timeout, which is why the block above is still needed.

Attachments go through the same path, so a proxy body-size limit has to clear what PHP itself accepts: upload_max_filesize is 25M and post_max_size is 60M in the image.

The failure mode is a proxy body limit smaller than PHP's. The upload is refused before Symfony sees it, so the message the user gets comes from the proxy and says nothing about attachments.

Webhooks that must reach you

If push is the point of putting plMail behind a proxy, these are the paths that have to arrive:

Path Who calls it How it proves itself
POST /gmail/push Google Cloud Pub/Sub ?token= matching GMAIL_PUBSUB_VERIFICATION_TOKEN, compared with hash_equals; no token configured means everything is refused
POST /webhook/graph/notify Microsoft Graph, mail clientState minted per subscription
POST /webhook/graph/lifecycle Microsoft Graph as above
POST /webhook/graph/calendar Microsoft Graph, calendar events as above
POST /webhook/google/calendar Google Calendar watch channel channel token in X-Goog-Channel-Token

All five are PUBLIC_ACCESS in the firewall, because the caller is a provider holding no session. Their addresses are built from APP_PUBLIC_URL, never from the incoming request — see Google and Microsoft for the console side, and the security model for what each token is doing.

Google additionally will not deliver calendar notifications to an unverified domain: the callback host has to be verified in the Cloud project that owns the OAuth client. Until then every events.watch is refused, which is at least visible as a warning in the log at registration time.

The failure mode is a proxy that only forwards GET. Every one of these is a POST, and a rule written for a read-only site drops them with a status the provider treats as a delivery failure and retries with backoff.

Things that bite

MERCURE_PUBLIC_URL is not derived on the stock compose file. The derivation from APP_PUBLIC_URL only happens when the variable is unset or empty, and compose.yaml sets it to https://localhost/.well-known/mercure. This is the single most likely reason live updates work in development and not in production.

Push registration failing is not an error state. Registration is retried hourly by app:calendar:push rather than being tied to the click that connected a calendar, so an install whose address or domain verification is fixed starts pushing within the hour without anybody re-subscribing anything.

APP_PUBLIC_URL set in the environment beats the setup screen permanently. That is the intended behaviour for a deployment that manages its own configuration — but it also means an operator who changes the address in the Compose file and expects the setup screen's value to matter is editing the wrong one, and vice versa.

A wrong TRUSTED_PROXIES looks like four unrelated bugs. OAuth mismatches, everyone sharing a login-attempt allowance, cookies without secure, and share links with the wrong scheme are all one setting.

Nothing here changes whether mail arrives. Polling is the backstop and it is unconditional: 15 minutes for mail, 15 for connected calendars, every minute for snooze wake and reminders. An install with no public address at all is a supported install; it is just never first to know.