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:
@@ -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,
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Webhook email transport (#1225).
|
||||
*
|
||||
* An alternative to SMTP: when EMAIL_WEBHOOK_URL is set, PicPeak POSTs the
|
||||
* composed message to that URL instead of sending it, and something downstream
|
||||
* (n8n, Make, a self-hosted relay) does the delivery.
|
||||
*
|
||||
* SMTP is the single most common thing people get stuck on in a self-hosted
|
||||
* install — app passwords, 587 vs 465, providers that reject the sender, NAS
|
||||
* boxes with no outbound 25. A webhook hands that problem to something the
|
||||
* operator usually already runs.
|
||||
*
|
||||
* Configured by environment, NOT in the admin UI. That is deliberate: this
|
||||
* setting redirects every outbound message, including password resets, so it
|
||||
* should not be changeable by a compromised admin session. It also matches how
|
||||
* the deploy that asked for this runs.
|
||||
*
|
||||
* Deliberately reuses the outbound-webhook primitives rather than growing a
|
||||
* second set: the same HMAC scheme (signPayload / X-PicPeak-Signature) so a
|
||||
* receiver verifies these exactly as it verifies gallery webhooks, and the same
|
||||
* DNS-resolving SSRF preflight.
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
const { signPayload } = require('./webhookService');
|
||||
const { validateExternalUrlAsync } = require('../utils/networkValidation');
|
||||
|
||||
const SIGNATURE_HEADER = 'X-PicPeak-Signature';
|
||||
const HTTP_TIMEOUT_MS = 15000;
|
||||
|
||||
// Attachments are base64 in the JSON body, which inflates them by a third.
|
||||
// Invoices and quotes are the real users of this and run to a few hundred KB;
|
||||
// the cap exists so a pathological attachment cannot build a payload large
|
||||
// enough to take the process down while serialising it.
|
||||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Same env-var shape as WEBHOOK_ALLOW_PRIVATE_URLS. Running n8n on the same
|
||||
// Docker network or LAN is the normal case for a self-hosted install, and
|
||||
// refusing private addresses outright would make this useless for exactly the
|
||||
// people who asked for it — so it is opt-in rather than assumed.
|
||||
let allowPrivateUrls = process.env.EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS === 'true';
|
||||
|
||||
// Logged once rather than per message: a misconfiguration is a property of the
|
||||
// deploy, and one line per outbound email would bury it.
|
||||
let warnedAboutMissingSecret = false;
|
||||
|
||||
function config() {
|
||||
return {
|
||||
url: (process.env.EMAIL_WEBHOOK_URL || '').trim(),
|
||||
secret: (process.env.EMAIL_WEBHOOK_SECRET || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the webhook transport configured and usable?
|
||||
*
|
||||
* A URL without a secret is treated as NOT enabled, and says so once. Sending
|
||||
* unsigned would let anything that learns the URL feed the operator's
|
||||
* automation — and every message PicPeak sends is one a receiver might act on.
|
||||
* Failing back to SMTP here means a misconfigured deploy sends by its normal
|
||||
* route rather than silently posting unauthenticated mail to the internet.
|
||||
*/
|
||||
function isEnabled() {
|
||||
const { url, secret } = config();
|
||||
if (!url) return false;
|
||||
if (!secret) {
|
||||
if (!warnedAboutMissingSecret) {
|
||||
warnedAboutMissingSecret = true;
|
||||
logger.error(
|
||||
'[email] EMAIL_WEBHOOK_URL is set but EMAIL_WEBHOOK_SECRET is not, so the '
|
||||
+ 'webhook transport is disabled and mail will go over SMTP. Set a secret: '
|
||||
+ 'the payload is signed with it (X-PicPeak-Signature), and without one '
|
||||
+ 'anything that learns the URL could drive your automation.'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn nodemailer's attachment list into something a JSON body can carry.
|
||||
*
|
||||
* Callers pass `{ filename, path }` for a file already written to disk (quotes
|
||||
* and invoices do this) or `{ filename, content }` for an in-memory buffer.
|
||||
* Both become base64.
|
||||
*
|
||||
* Throws rather than dropping. The downstream implementation this was modelled
|
||||
* on logged a warning and sent the body without its attachment, which turns
|
||||
* "your invoice email failed" into "your customer received an empty invoice
|
||||
* email" — a silent partial success is the worse outcome, and the email queue
|
||||
* already surfaces and retries a throw.
|
||||
*/
|
||||
async function encodeAttachments(attachments) {
|
||||
if (!Array.isArray(attachments) || attachments.length === 0) return [];
|
||||
|
||||
const encoded = [];
|
||||
let total = 0;
|
||||
for (const att of attachments) {
|
||||
if (!att) continue;
|
||||
let buffer;
|
||||
if (att.content) {
|
||||
buffer = Buffer.isBuffer(att.content) ? att.content : Buffer.from(att.content);
|
||||
} else if (att.path) {
|
||||
// stat BEFORE reading. Checking the cap only after readFile means a file
|
||||
// large enough to exhaust memory kills the process before the guard it is
|
||||
// supposed to trip — the cap would exist and never fire. The post-read
|
||||
// check below still applies, because the file can grow between the two.
|
||||
const { size } = await fs.stat(att.path);
|
||||
if (total + size > MAX_ATTACHMENT_BYTES) {
|
||||
throw new Error(
|
||||
`attachments exceed the ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB `
|
||||
+ 'webhook payload limit; send this message over SMTP instead'
|
||||
);
|
||||
}
|
||||
buffer = await fs.readFile(att.path);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
total += buffer.length;
|
||||
if (total > MAX_ATTACHMENT_BYTES) {
|
||||
throw new Error(
|
||||
`attachments exceed the ${Math.round(MAX_ATTACHMENT_BYTES / 1024 / 1024)}MB `
|
||||
+ 'webhook payload limit; send this message over SMTP instead'
|
||||
);
|
||||
}
|
||||
|
||||
encoded.push({
|
||||
filename: att.filename,
|
||||
content_type: att.contentType || 'application/octet-stream',
|
||||
content_base64: buffer.toString('base64'),
|
||||
});
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipients as a flat list of single addresses.
|
||||
*
|
||||
* Splits inside array elements too, not just bare strings: sendRawEmail wraps a
|
||||
* string cc in an array before it reaches here, so "a@x, b@y" arrives as ONE
|
||||
* element. Passing that through would put a combined address in the payload,
|
||||
* which a relay treating each element as one mailbox rejects or misaddresses.
|
||||
*/
|
||||
/**
|
||||
* Read at most MAX_RESPONSE_BYTES from a response stream, then stop.
|
||||
*
|
||||
* Only a messageId is wanted, so the rest is dropped on the floor rather than
|
||||
* buffered — a faulty or hostile receiver must not be able to grow this
|
||||
* process's memory on every queue attempt. Any read failure resolves empty:
|
||||
* the delivery verdict is the status code, which is already known by the time
|
||||
* this runs, so a broken body must never turn a delivered message into a retry.
|
||||
*/
|
||||
function readBounded(stream) {
|
||||
const MAX_RESPONSE_BYTES = 10 * 1024;
|
||||
if (!stream || typeof stream.on !== 'function') return Promise.resolve('');
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
const finish = () => resolve(Buffer.concat(chunks).toString('utf8'));
|
||||
stream.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size <= MAX_RESPONSE_BYTES) {
|
||||
chunks.push(chunk);
|
||||
} else {
|
||||
stream.destroy();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
stream.on('end', finish);
|
||||
stream.on('error', () => resolve(''));
|
||||
stream.on('close', finish);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRecipients(value) {
|
||||
if (!value) return [];
|
||||
const parts = Array.isArray(value) ? value : [value];
|
||||
return parts
|
||||
.filter(Boolean)
|
||||
.flatMap((entry) => String(entry).split(/[,;]+/))
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a composed message to the configured webhook.
|
||||
*
|
||||
* @param {Object} mail nodemailer-shaped options (from/to/cc/subject/html/text/attachments)
|
||||
* @returns {Promise<{ messageId: string }>} so callers match the sendMail contract
|
||||
*/
|
||||
async function send(mail) {
|
||||
const { url, secret } = config();
|
||||
|
||||
// Vetted before every send, not once at startup: DNS answers change, and the
|
||||
// check is what stops an operator-supplied URL becoming a request to link
|
||||
// local metadata or a service on the host network.
|
||||
if (!allowPrivateUrls) {
|
||||
// https 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, which are usable by anyone on the path. The
|
||||
// private-network opt-in doubles as the plaintext opt-in, because http to
|
||||
// a container on the same host is a different risk from http across the
|
||||
// internet.
|
||||
if (!/^https:\/\//i.test(url)) {
|
||||
throw new Error(
|
||||
'EMAIL_WEBHOOK_URL must use https:// — the payload carries password-reset '
|
||||
+ 'links and recovery codes, which plaintext exposes to anyone on the path. '
|
||||
+ 'Set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true only if the receiver is on a '
|
||||
+ 'private network you trust.'
|
||||
);
|
||||
}
|
||||
const check = await validateExternalUrlAsync(url);
|
||||
if (!check.valid) {
|
||||
throw new Error(
|
||||
`EMAIL_WEBHOOK_URL rejected: ${check.error}. Set `
|
||||
+ 'EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true if the receiver really is on a '
|
||||
+ 'private network (a container or LAN address).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from: mail.from,
|
||||
to: normalizeRecipients(mail.to),
|
||||
cc: normalizeRecipients(mail.cc),
|
||||
subject: mail.subject || '',
|
||||
html: mail.html || '',
|
||||
text: mail.text || '',
|
||||
attachments: await encodeAttachments(mail.attachments),
|
||||
};
|
||||
|
||||
// Signed over the exact bytes sent, so a receiver verifies what it received
|
||||
// rather than a re-serialisation of it.
|
||||
const rawBody = JSON.stringify(payload);
|
||||
const signature = signPayload(secret, rawBody);
|
||||
|
||||
const response = await axios.post(url, rawBody, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[SIGNATURE_HEADER]: signature,
|
||||
},
|
||||
timeout: HTTP_TIMEOUT_MS,
|
||||
// Resolve on any status so a 4xx/5xx becomes our error message rather than
|
||||
// axios's, which does not say which webhook failed.
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 0,
|
||||
// Streamed, NOT buffered with maxContentLength. axios enforces that limit
|
||||
// while reading, so a receiver that delivered the mail and then echoed a
|
||||
// large body would make this throw AFTER a successful delivery — the queue
|
||||
// would retry and the recipient would get the same email again. Reading it
|
||||
// ourselves means an oversized response costs us the messageId, never a
|
||||
// duplicate send.
|
||||
responseType: 'stream',
|
||||
// Byte length, not String#length. axios enforces this against the UTF-8
|
||||
// buffer it sends, while rawBody.length counts UTF-16 code units — every
|
||||
// umlaut is 2 bytes and every CJK character 3, so a German or Japanese
|
||||
// message would blow past a code-unit budget and axios would reject it
|
||||
// before posting. With base64 attachments in the body the gap is easily
|
||||
// more than the slack.
|
||||
maxBodyLength: Buffer.byteLength(rawBody, 'utf8') + 1024,
|
||||
});
|
||||
|
||||
// Status first: it is the delivery verdict, and it is known before a single
|
||||
// byte of the body is read.
|
||||
const delivered = response.status >= 200 && response.status < 300;
|
||||
const body = await readBounded(response.data);
|
||||
if (!delivered) {
|
||||
throw new Error(`email webhook returned ${response.status}`);
|
||||
}
|
||||
|
||||
// The queue stores a messageId for the record. There is no SMTP id here, so
|
||||
// synthesise one that is obviously not from a mail server.
|
||||
let reported = null;
|
||||
try {
|
||||
reported = body ? JSON.parse(body).messageId : null;
|
||||
} catch { /* a receiver is not obliged to answer JSON */ }
|
||||
return { messageId: reported || `webhook-${signature.slice(0, 16)}` };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isEnabled,
|
||||
send,
|
||||
// Test seams, mirroring webhookDeliveryWorker's.
|
||||
__testing: {
|
||||
SIGNATURE_HEADER,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
encodeAttachments,
|
||||
setAllowPrivateUrls(value) { allowPrivateUrls = !!value; },
|
||||
resetSecretWarning() { warnedAboutMissingSecret = false; },
|
||||
},
|
||||
};
|
||||
@@ -13,7 +13,8 @@ const crypto = require('crypto');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { initializeTransporter, wrapEmailHtml } = require('./emailProcessor');
|
||||
const { initializeTransporter, wrapEmailHtml, resolveFromIdentity } = require('./emailProcessor');
|
||||
const emailWebhookTransport = require('./emailWebhookTransport');
|
||||
|
||||
const CODE_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
@@ -45,13 +46,22 @@ async function createCode(eventId, email) {
|
||||
}
|
||||
|
||||
async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
|
||||
const transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
// This composes its own message rather than going through a template, so it
|
||||
// has to select the transport itself (#1225). Without this it called
|
||||
// initializeTransporter() unconditionally and dereferenced the null it
|
||||
// returns on a webhook-only install — recovery codes failed outright on
|
||||
// exactly the deploys the webhook transport exists for.
|
||||
const viaWebhook = emailWebhookTransport.isEnabled();
|
||||
let transporter = null;
|
||||
if (!viaWebhook) {
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
}
|
||||
|
||||
const config = await db('email_configs').first();
|
||||
if (!config) {
|
||||
const identity = await resolveFromIdentity();
|
||||
if (!identity) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
@@ -70,13 +80,18 @@ async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
|
||||
`;
|
||||
const styledHtml = await wrapEmailHtml(htmlBody, subject, 'en');
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `${config.from_name} <${config.from_email}>`,
|
||||
const mail = {
|
||||
from: `${identity.fromName} <${identity.fromEmail}>`,
|
||||
to: toEmail,
|
||||
subject,
|
||||
html: styledHtml,
|
||||
text: `Your verification code is ${code}. It expires in 15 minutes.`,
|
||||
});
|
||||
};
|
||||
if (viaWebhook) {
|
||||
await emailWebhookTransport.send(mail);
|
||||
} else {
|
||||
await transporter.sendMail(mail);
|
||||
}
|
||||
|
||||
logger.info('Guest recovery code sent', { email: toEmail });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const { db } = require('../database/db');
|
||||
const { checkForUpdates } = require('./updateCheckService');
|
||||
const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor');
|
||||
const emailWebhookTransport = require('./emailWebhookTransport');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAbsoluteFrontendUrl } = require('../utils/frontendUrl');
|
||||
|
||||
@@ -126,7 +127,9 @@ async function checkAndNotifyUpdates() {
|
||||
}
|
||||
|
||||
// Ensure email transporter is initialized
|
||||
await initializeTransporter();
|
||||
// Skipped under the webhook transport (#1225): there is no SMTP to warm,
|
||||
// and a stale unreachable config would sit on nodemailer's connect timeout.
|
||||
if (!emailWebhookTransport.isEnabled()) await initializeTransporter();
|
||||
|
||||
// Send email to each recipient
|
||||
const frontendUrl = await getAbsoluteFrontendUrl();
|
||||
@@ -215,7 +218,9 @@ async function sendTestUpdateNotification() {
|
||||
|
||||
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
|
||||
|
||||
await initializeTransporter();
|
||||
// Skipped under the webhook transport (#1225): there is no SMTP to warm,
|
||||
// and a stale unreachable config would sit on nodemailer's connect timeout.
|
||||
if (!emailWebhookTransport.isEnabled()) await initializeTransporter();
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
Reference in New Issue
Block a user