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
+9 -2
View File
@@ -81,6 +81,7 @@ const { startDownloadJobCleanup } = require('./src/services/downloadJobCleanupSe
const { startRevealScheduler } = require('./src/services/revealScheduler');
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const emailWebhookTransport = require('./src/services/emailWebhookTransport');
const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup');
const backgroundProcessor = require('./src/services/backgroundProcessor');
@@ -1069,8 +1070,14 @@ async function startServer() {
// flag is OFF (the service short-circuits on empty result sets).
startInvoiceScheduler();
// Initialize email transporter and start queue processor
await initializeTransporter();
// Initialize email transporter and start queue processor.
// Skipped under the webhook transport (#1225): an install that switched to
// it may still carry an old, now-unreachable SMTP row, and nodemailer's
// verify() would sit on a connection timeout here — delaying boot for a
// transport that will never send anything.
if (!emailWebhookTransport.isEnabled()) {
await initializeTransporter();
}
// Seed CRM / contract / event-reminder email templates and recover
// any queue rows that exhausted retries because their template
// didn't exist yet. Runs once per boot via module-level caches in