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.
This commit is contained in:
Paul Nothaft
2026-08-29 11:10:32 +02:00
committed by GitHub
parent f4c054a661
commit d62407f431
9 changed files with 774 additions and 36 deletions
+36
View File
@@ -99,6 +99,42 @@ DB_NAME=picpeak_prod
#SMTP_PASS=your-app-specific-password
#[email protected]
# Webhook email transport (#1225) — OPTIONAL, an alternative to SMTP entirely.
# When EMAIL_WEBHOOK_URL is set, PicPeak stops sending mail itself and POSTs
# each composed message as JSON to that URL instead; something downstream
# (n8n, Make, a self-hosted relay) delivers it. Useful when SMTP is the part
# you cannot get working — app passwords, blocked ports, a NAS with no
# outbound 25.
#
# Deliberately environment-only, not an admin setting: it redirects every
# outbound message including password resets, so it should not be changeable
# from a compromised admin session.
#
# EMAIL_WEBHOOK_SECRET is REQUIRED. The body is signed with it and sent as
# X-PicPeak-Signature (HMAC-SHA256, hex) — the same scheme as gallery
# webhooks, so a receiver verifies both the same way. Set the URL without a
# secret and the transport stays OFF and says so in the log, rather than
# posting unauthenticated mail to the internet.
#
# Payload: { from, to[], cc[], subject, html, text, attachments[] }, where each
# attachment is { filename, content_type, content_base64 }. Attachments are
# included rather than dropped; a message whose attachments exceed 10 MB fails
# and stays in the queue instead of arriving without its invoice.
#
# The receiver must be a public https:// address unless you opt in — a container or LAN
# address is refused by the SSRF check otherwise. Running n8n beside PicPeak is
# normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that.
#
# A mail account with its own SMTP host (Settings -> Mail accounts) keeps
# sending through it; this replaces the global transport only.
#
# Set EMAIL_FROM above as well. A webhook-only install never gets an
# email_configs row (that is seeded only when SMTP_HOST is set), so EMAIL_FROM
# is where the sender address comes from.
#EMAIL_WEBHOOK_URL=https://n8n.example.com/webhook/picpeak-mail
#EMAIL_WEBHOOK_SECRET=generate-a-long-random-string
#EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=false
# Application URLs — OPTIONAL. Leave unset for the normal install.
# The public origin is captured by the setup wizard (it proposes the address
# you opened the browser at) and stored as the `general_site_url` setting, so