docs: FINDING — app-email rollout mechanism gaps (calcom/nextcloud/immich)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# 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 image
|
||||
`NODE_ENV=production` ⇒ `rejectUnauthorized: true`, with **no env override** (and `NODE_TLS_REJECT_UNAUTHORIZED`
|
||||
does not help — an explicit per-transport TLS option wins). `secure` is 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.php` exposes **no** `mail_smtpstreamoptions` /
|
||||
`verify_peer=false`, so `SMTP_SECURE=tls|ssl` fail 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):**
|
||||
1. **A plaintext-only listener that does NOT advertise STARTTLS** (e.g. `:2526`), selectable per app via the
|
||||
mapping (a `tls_mode: plain-only` hint). Opportunistic-upgrade clients then never try TLS → send succeeds.
|
||||
Lowest-effort, highest-coverage fix.
|
||||
2. **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`**:
|
||||
```json
|
||||
{ "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 to `3.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 to `SMTP_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.
|
||||
Reference in New Issue
Block a user