Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.4 KiB
FINDING — app-email rollout: 3 apps reveal relay mechanism gaps
Date: 2026-06-29
Context: Rolling out the shipped app-email relay (controller v0.88.0 in-process shim + smtp_mapping
injection; hub v0.18.0 passthrough) to calcom, rallly, gitea, nextcloud (+ investigate immich). Per the task's
rule 4, an app that needs an injection feature the mechanism can't provide is reported here, not hacked
around in the catalog.
Outcome: gitea and rallly wired cleanly (STARTTLS, both trust/accept the shim's self-signed cert). cal.com, nextcloud, and immich each hit a real gap and are left unwired pending a mechanism decision. The gaps cluster into three categories below.
The shipped mechanism (recap)
The on-box shim offers :2525 plaintext with STARTTLS advertised (self-signed cert) and :2465 implicit
TLS (self-signed). The smtp_mapping injects, per app, a set of env vars: a single host/port/security-mode/
single From address/optional name + fixed extra vars. Two app shapes work today:
- Vaultwarden shape — STARTTLS + an app flag to accept the self-signed cert (
SMTP_ACCEPT_INVALID_CERTS). - Mealie shape — plaintext, and the app's client does not opportunistically upgrade to STARTTLS.
The gaps below are all cases that fit neither shape.
Gap 1 — Self-signed STARTTLS vs opportunistic-upgrade clients with no skip-verify
Affects: cal.com (hard), nextcloud (TLS half).
Nodemailer (cal.com, rallly) and Symfony Mailer (nextcloud) opportunistically issue STARTTLS whenever the
server advertises it — which our :2525 listener always does — and then validate the cert. If the app has
no "accept invalid cert / reject-unauthorized=false" knob, the TLS handshake fails on the self-signed cert and
the send errors out (these clients do not fall back to plaintext). Mealie avoided this only because its
client never attempts STARTTLS.
- cal.com v4.8.7 — transport hard-codes
tls: { rejectUnauthorized: !isENVDev }; in the production imageNODE_ENV=production⇒rejectUnauthorized: true, with no env override (andNODE_TLS_REJECT_UNAUTHORIZEDdoes not help — an explicit per-transport TLS option wins).secureis true only for port 465, so every other port plaintext-connects then opportunistically STARTTLS-upgrades → self-signed failure; 2465 implicit-TLS also fails the same cert check. No env-only path exists. Source:packages/lib/serverConfig.ts@ v4.8.7. - nextcloud 31 — the official image's
smtp.config.phpexposes nomail_smtpstreamoptions/verify_peer=false, soSMTP_SECURE=tls|sslfail the self-signed cert with no override.SMTP_SECURE=''(none) avoids configured TLS, but NC31's Symfony mailer may still auto-STARTTLS when the shim advertises it.
Fix options (controller mechanism — out of scope for the catalog):
- A plaintext-only listener that does NOT advertise STARTTLS (e.g.
:2526), selectable per app via the mapping (atls_mode: plain-onlyhint). Opportunistic-upgrade clients then never try TLS → send succeeds. Lowest-effort, highest-coverage fix. - Issue the shim a cert from an internal CA and distribute that CA into app trust stores. Heavier (per-image trust injection), but lets apps use real STARTTLS without a skip flag.
Recommendation: option 1 (a non-advertising plaintext listener) — it directly unblocks cal.com and the nextcloud TLS half, and is a small, contained shim change.
Gap 2 — Split From address (local-part + domain in separate env vars)
Affects: nextcloud.
The smtp_mapping from_var injects a single <local>@<domain> value. Nextcloud's official image splits
the sender into MAIL_FROM_ADDRESS (local-part, e.g. nextcloud) + MAIL_DOMAIN (felhom.eu). There
is no single From env var to target. (Hacking it via extra overriding from_var by append-order is fragile
and misleading — explicitly avoided.)
Fix (controller mechanism): add an optional from_domain_var (+ keep from_local) so a mapping can set the
local-part and domain to two separate env keys. Small, additive change to SMTPMapping + the injector.
Gap 3 — No SMTP env vars at all (config-file / DB / admin-UI only)
Affects: immich.
immich v2.5.5 has no SMTP environment variables. Email is system config (Administration → Settings →
Notifications), stored in the DB, OR supplied via a JSON file pointed to by IMMICH_CONFIG_FILE:
{ "notifications": { "smtp": { "enabled": true, "from": "immich@felhom.eu",
"transport": { "host": "felhom-controller", "port": 2525, "secure": false, "ignoreCert": true } } } }
Note ignoreCert: true — immich can accept the self-signed shim cert, so the only blocker is the
delivery mechanism: env injection can't express this; the controller would need to render + mount a
config file (a new, larger capability) or the customer configures SMTP once in the admin UI.
Fix (separate, larger piece): a "config-file injection for DB/file-configured apps" mechanism (render an app-specific config file from catalog metadata + the relay settings, mount it, set the pointer env). immich, and likely other DB-configured apps, would ride it. Until then immich is manual admin-UI setup.
Wired now (this rollout)
| App | Shape | Self-signed handling | From | Env consumed |
|---|---|---|---|---|
| gitea 1.23.4 | STARTTLS (smtp+starttls) |
FORCE_TRUST_SERVER_CERT=true |
single GITEA__mailer__FROM |
every boot |
| rallly 3.11.2 | STARTTLS (SMTP_SECURE=false) |
SMTP_REJECT_UNAUTHORIZED=false (v4) / default-off (v3) |
single NOREPLY_EMAIL |
every start |
Side-finding (fixed): the rallly template pinned
lukevella/rallly:3.12.1, which does not exist (the 3.x line stops at 3.11.x; current is 4.x) — the template was undeployable. Corrected to3.11.2(nearest valid 3.x) so it deploys + can be email-tested. A later bump to 4.x is a separate decision (4.x renames the cert flag toSMTP_REJECT_UNAUTHORIZED, already in the mapping).
Remaining email-capable apps still unwired (future passes)
ghost, outline, grafana, docmost, bookstack, n8n, paperless-ngx, gitea-actions notifications, … — each needs the same per-app determination (env names + self-signed/opportunistic-TLS behavior + single/split From). Apps that opportunistically STARTTLS without a skip flag will need Gap-1 option 1 first.