Files
picpeak/backend/src/services
Paul Nothaft d62407f431 feat(email): webhook transport as an alternative to SMTP (#1225) (#1231)
Setting EMAIL_WEBHOOK_URL makes PicPeak stop sending mail itself and POST each
composed message as JSON instead, for something downstream (n8n, Make, a
self-hosted relay) to deliver. Unset, every SMTP path is unchanged.

Settles the four things #1225 left open:

- SSRF: the URL goes through the same DNS-resolving check the outbound webhook
  worker uses, before every send. Private receivers are opt-in.
- Transport security: https is required for anything leaving the machine. The
  HMAC proves who sent the body, not who can read it, and these bodies carry
  password-reset links and guest recovery codes. The private-network opt-in
  doubles as the plaintext opt-in.
- Authentication: EMAIL_WEBHOOK_SECRET is required and signs the body as
  X-PicPeak-Signature, the same scheme as gallery webhooks. A URL without a
  secret leaves the transport OFF and says so once.
- Attachments: carried as base64, not dropped. Oversized ones fail and stay
  queued rather than arriving without the invoice.

Configuration is environment-only on purpose: this redirects every outbound
message including password resets, so it must not be changeable from a
compromised admin session.

Three wiring details decide whether it works at all: docker-compose.yml
declares an explicit environment block, so the vars had to be forwarded
there; a fresh webhook-only install has no email_configs row (migration 001
seeds it only when SMTP_HOST is set), so the From identity falls back to
EMAIL_FROM; and processEmailQueue used to return early when SMTP could not
initialise, which would have left the queue permanently unprocessed.

guestRecoveryService and the admin test-email endpoint were bypassing the
transport — the first dereferenced a null transporter, the second told
webhook-only admins to go configure SMTP. emailIntakeService deliberately
stays on SMTP: it round-trips a specific mailbox's own credentials.

Response handling is streamed and read bounded by hand rather than capped via
axios: maxContentLength throws while reading, so a receiver that delivered the
mail and then echoed a large body would have been recorded as failed and the
message sent again.

Note: docker-compose.dev.yml is gitignored and local-only, so the equivalent
entries there are not part of this change. docker-compose.production.yml needs
none — it passes .env through with env_file.

Three rounds of external review; 21 transport tests, 61 across the email
suites.
2026-08-29 11:10:32 +02:00
..