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
+85 -21
View File
@@ -7,6 +7,33 @@ const {
normaliseSchedule,
} = require('../utils/businessHours');
const { hasColumnCached } = require('../utils/schemaCache');
const emailWebhookTransport = require('./emailWebhookTransport');
/**
* The From identity for an outbound message (#1225).
*
* `email_configs` holds it normally, but migration 001 seeds that row only when
* SMTP_HOST is set — so the install this feature exists for, a fresh one with
* no SMTP at all, has no row and every send would die on "Email configuration
* not found". Under the webhook transport the address therefore falls back to
* EMAIL_FROM, which already exists for config-as-code deploys.
*
* Returns null when nothing is configured, so callers keep their existing
* error. SMTP behaviour is unchanged: the fallback only applies in webhook mode.
*/
async function resolveFromIdentity() {
const config = await db('email_configs').first();
if (config && config.from_email) {
return { fromEmail: config.from_email, fromName: config.from_name };
}
if (emailWebhookTransport.isEnabled() && process.env.EMAIL_FROM) {
return {
fromEmail: process.env.EMAIL_FROM,
fromName: process.env.EMAIL_FROM_NAME || 'PicPeak',
};
}
return null;
}
let transporter = null;
let lastConfigHash = null;
@@ -105,6 +132,12 @@ async function getSupportEmail() {
} catch (err) {
logger.debug('getSupportEmail: email_configs lookup failed', { error: err.message });
}
// Same reason as resolveFromIdentity (#1225): a webhook-only install has no
// email_configs row, and returning '' here silently drops the support
// contact out of the archive and expiration templates that print it.
if (emailWebhookTransport.isEnabled() && process.env.EMAIL_FROM) {
return process.env.EMAIL_FROM;
}
return '';
}
@@ -705,10 +738,16 @@ async function processTemplate(template, variables, language = 'en') {
// Send email using template
async function sendTemplateEmail(to, templateKey, variables) {
try {
// Always check for configuration changes before sending
transporter = await initializeTransporter();
if (!transporter) {
throw new Error('Email service not configured');
// Webhook transport (#1225) replaces SMTP entirely when configured, so an
// instance using it has no SMTP settings to initialise and must not be
// told it is "not configured".
const viaWebhook = emailWebhookTransport.isEnabled();
if (!viaWebhook) {
// Always check for configuration changes before sending
transporter = await initializeTransporter();
if (!transporter) {
throw new Error('Email service not configured');
}
}
// Get email template
@@ -720,10 +759,15 @@ async function sendTemplateEmail(to, templateKey, variables) {
throw new Error(`Email template '${templateKey}' not found`);
}
// Get email config for from address
const config = await db('email_configs').first();
if (!config) {
throw new Error('Email configuration not found');
// Get the From identity. Under the webhook transport this can come from
// EMAIL_FROM, because a webhook-only install has no email_configs row.
const identity = await resolveFromIdentity();
if (!identity) {
throw new Error(
viaWebhook
? 'No sender address configured — set EMAIL_FROM for the webhook transport'
: 'Email configuration not found'
);
}
// Determine recipient language. An explicit `__language` in the email data
@@ -755,15 +799,18 @@ async function sendTemplateEmail(to, templateKey, variables) {
: undefined;
// Send email
const info = await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
const mail = {
from: `${identity.fromName} <${identity.fromEmail}>`,
to: to,
cc: ccList,
subject: subject,
html: htmlBody,
text: textBody || htmlToText(htmlBody),
attachments,
});
};
const info = viaWebhook
? await emailWebhookTransport.send(mail)
: await transporter.sendMail(mail);
logger.info(`Email sent successfully: ${info.messageId} (${language})`);
// Return the rendered HTML so the queue processor can persist the ACTUAL
@@ -804,13 +851,22 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
fromName = acct.from_name || '';
}
}
// Webhook transport (#1225) stands in for the GLOBAL transport only. A mail
// account with its own smtp_host above was configured deliberately for that
// identity, so it keeps sending through it rather than being silently
// redirected.
let viaWebhook = false;
if (!tx) {
tx = await initializeTransporter();
if (!tx) throw new Error('Email service not configured');
const config = await db('email_configs').first();
if (!config || !config.from_email) throw new Error('Email service not configured');
fromEmail = config.from_email;
fromName = config.from_name;
const identity = await resolveFromIdentity();
if (!identity) throw new Error('Email service not configured');
fromEmail = identity.fromEmail;
fromName = identity.fromName;
if (emailWebhookTransport.isEnabled()) {
viaWebhook = true;
} else {
tx = await initializeTransporter();
if (!tx) throw new Error('Email service not configured');
}
}
const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined);
@@ -818,7 +874,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
? attachments.filter((a) => a && (a.contentPath || a.path || a.content))
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
: undefined;
const info = await tx.sendMail({
const mail = {
from: `${fromName || 'picpeak'} <${fromEmail}>`,
to,
cc: ccList,
@@ -826,7 +882,10 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
html,
text: text || htmlToText(html),
attachments: atts,
});
};
const info = viaWebhook
? await emailWebhookTransport.send(mail)
: await tx.sendMail(mail);
logger.info(`Manual email sent: ${info.messageId}`);
return { messageId: info.messageId, html };
}
@@ -868,8 +927,12 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
const result = { processed: 0, sent: 0, failed: 0 };
try {
// Try to initialize transporter if it's null (in case it failed at startup)
if (!transporter) {
// Try to initialize transporter if it's null (in case it failed at startup).
// Skipped entirely under the webhook transport (#1225): that deploy has no
// SMTP settings to initialise, and this guard would otherwise return early
// and leave the queue permanently unprocessed — every email silently stuck
// pending, which is the whole feature dead rather than degraded.
if (!transporter && !emailWebhookTransport.isEnabled()) {
logger.info('Transporter not initialized, attempting to initialize...');
transporter = await initializeTransporter();
if (!transporter) {
@@ -1162,6 +1225,7 @@ function stopEmailQueueProcessor() {
module.exports = {
initializeTransporter,
resolveFromIdentity,
startEmailQueueProcessor,
sendTemplateEmail,
sendRawEmail,