fix(api): add custom headers to email fallback SMTP (#3507)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-17 02:38:59 +02:00
1 parent 0b1ddc2695
commit 557efe1c7d
13 files changed
+132 -4

No files matched your search

+3
View File
@@ -334,6 +334,9 @@ FLUXER_EMAIL_SMTP_TLS_MODE=
#FLUXER_EMAIL_FALLBACK_SMTP_USERNAME= #FLUXER_EMAIL_FALLBACK_SMTP_USERNAME=
#FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD= #FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD=
#FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE= #FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE=
# Extra headers on every fallback message, as a JSON object of header name to
# string value. Use single quotes so the shell keeps the JSON intact.
#FLUXER_EMAIL_FALLBACK_SMTP_HEADERS='{"X-Example-Tracking":"off"}'
#FLUXER_EMAIL_WEBHOOK_SECRET= #FLUXER_EMAIL_WEBHOOK_SECRET=
FLUXER_DISCOVERY_ENABLED=true FLUXER_DISCOVERY_ENABLED=true
+1
View File
@@ -132,6 +132,7 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: ${FLUXER_EMAIL_FALLBACK_SMTP_USERNAME:-} FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: ${FLUXER_EMAIL_FALLBACK_SMTP_USERNAME:-}
FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ${FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD:-} FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ${FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD:-}
FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: ${FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE:-} FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: ${FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE:-}
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: ${FLUXER_EMAIL_FALLBACK_SMTP_HEADERS:-}
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-} FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-} FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-}
@@ -25,4 +25,33 @@ describe('SmtpEmailProvider', () => {
new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode}); new SmtpEmailProvider({host: 'smtp.example.com', port, username: 'user', password: 'pass', tlsMode});
expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected)); expect(createTransport).toHaveBeenCalledWith(expect.objectContaining(expected));
}); });
const message = {
to: '[email protected]',
from: {email: '[email protected]', name: 'Example'},
subject: 'Hi',
text: 'Body',
};
it('sends the configured headers verbatim', async () => {
const provider = new SmtpEmailProvider({
host: 'smtp.example.com',
port: 587,
username: 'user',
password: 'pass',
headers: {'X-Tracking': '{"enable":0}'},
});
await provider.sendEmail(message);
const {sendMail} = createTransport.mock.results[0].value;
expect(sendMail).toHaveBeenCalledWith(
expect.objectContaining({headers: {'X-Tracking': {prepared: true, value: '{"enable":0}'}}}),
);
});
it('sends no extra headers when none are configured', async () => {
const provider = new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'});
await provider.sendEmail(message);
const {sendMail} = createTransport.mock.results[0].value;
expect(sendMail.mock.calls[0][0]).not.toHaveProperty('headers');
});
}); });
@@ -13,6 +13,7 @@ interface SmtpEmailConfig {
username: string; username: string;
password: string; password: string;
tlsMode?: SmtpTlsMode | null; tlsMode?: SmtpTlsMode | null;
headers?: Record<string, string>;
connectionTimeoutMs?: number; connectionTimeoutMs?: number;
greetingTimeoutMs?: number; greetingTimeoutMs?: number;
socketTimeoutMs?: number; socketTimeoutMs?: number;
@@ -23,11 +24,17 @@ export class SmtpEmailProvider implements IEmailProvider {
private readonly host: string; private readonly host: string;
private readonly port: number; private readonly port: number;
private readonly tlsMode: SmtpTlsMode; private readonly tlsMode: SmtpTlsMode;
private readonly headers: Record<string, {prepared: true; value: string}> | undefined;
constructor(config: SmtpEmailConfig) { constructor(config: SmtpEmailConfig) {
this.host = config.host; this.host = config.host;
this.port = config.port; this.port = config.port;
this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port); this.tlsMode = resolveSmtpTlsMode(config.tlsMode, config.port);
this.headers = config.headers
? Object.fromEntries(
Object.entries(config.headers).map(([name, value]) => [name, {prepared: true as const, value}]),
)
: undefined;
this.transporter = nodemailer.createTransport({ this.transporter = nodemailer.createTransport({
host: config.host, host: config.host,
port: config.port, port: config.port,
@@ -55,6 +62,7 @@ export class SmtpEmailProvider implements IEmailProvider {
to: message.to, to: message.to,
from: `${message.from.name} <${message.from.email}>`, from: `${message.from.name} <${message.from.email}>`,
...(message.replyTo ? {replyTo: message.replyTo} : {}), ...(message.replyTo ? {replyTo: message.replyTo} : {}),
...(this.headers ? {headers: this.headers} : {}),
subject: message.subject, subject: message.subject,
text: message.text, text: message.text,
}); });
+4 -1
View File
@@ -307,7 +307,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
fallback: master.integrations.email.fallback fallback: master.integrations.email.fallback
? { ? {
domains: master.integrations.email.fallback.domains, domains: master.integrations.email.fallback.domains,
smtp: toSmtpConfig(master.integrations.email.fallback.smtp), smtp: {
...toSmtpConfig(master.integrations.email.fallback.smtp),
headers: master.integrations.email.fallback.smtp.headers,
},
} }
: undefined, : undefined,
}, },
+1 -1
View File
@@ -173,7 +173,7 @@ export interface APIConfig {
smtp?: SmtpConfig; smtp?: SmtpConfig;
fallback?: { fallback?: {
domains: Array<string>; domains: Array<string>;
smtp: SmtpConfig; smtp: SmtpConfig & {headers?: Record<string, string>};
}; };
}; };
blocklistFeeds: { blocklistFeeds: {
@@ -4,7 +4,8 @@ import type {APIConfig} from '@app/api/config/APIConfig';
import {createEmailProvider} from '@app/api/email/EmailProviderFactory'; import {createEmailProvider} from '@app/api/email/EmailProviderFactory';
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider'; import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider'; import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import {describe, expect, it} from 'vitest'; import nodemailer, {type Transporter} from 'nodemailer';
import {describe, expect, it, vi} from 'vitest';
const smtp = {host: 'smtp.example.com', port: 587, username: 'user', password: 'pass', tlsMode: null}; const smtp = {host: 'smtp.example.com', port: 587, username: 'user', password: 'pass', tlsMode: null};
@@ -33,6 +34,18 @@ describe('createEmailProvider', () => {
expect(provider).toBeInstanceOf(DomainRoutedEmailProvider); expect(provider).toBeInstanceOf(DomainRoutedEmailProvider);
}); });
it('adds the fallback headers only to mail through the fallback', async () => {
const sendMail = vi.fn();
vi.spyOn(nodemailer, 'createTransport').mockReturnValue({sendMail} as unknown as Transporter);
const headers = {'X-Tracking': '{"enable":0}'};
const provider = createEmailProvider(emailConfig({fallback: {domains: ['gmail.com'], smtp: {...smtp, headers}}}));
const from = {email: '[email protected]', name: 'Example'};
await provider?.sendEmail({to: '[email protected]', from, subject: 'Hi', text: 'Body'});
await provider?.sendEmail({to: '[email protected]', from, subject: 'Hi', text: 'Body'});
expect(sendMail.mock.calls[0][0].headers).toEqual({'X-Tracking': {prepared: true, value: '{"enable":0}'}});
expect(sendMail.mock.calls[1][0]).not.toHaveProperty('headers');
});
it.each([{enabled: false}, {provider: 'none' as const}, {smtp: undefined}])( it.each([{enabled: false}, {provider: 'none' as const}, {smtp: undefined}])(
'returns no provider for %j even with a fallback', 'returns no provider for %j even with a fallback',
(overrides) => { (overrides) => {
@@ -865,6 +865,10 @@ Default empty. The fallback SMTP password. Required when the domains are set.
Default empty. How the fallback connection is encrypted, with the same four values as `FLUXER_EMAIL_SMTP_TLS_MODE`. An empty value picks `implicit` on port 465 and `starttls` on every other port. A failed send through the fallback is logged as `SMTP send failed` with the fallback host. Default empty. How the fallback connection is encrypted, with the same four values as `FLUXER_EMAIL_SMTP_TLS_MODE`. An empty value picks `implicit` on port 465 and `starttls` on every other port. A failed send through the fallback is logged as `SMTP send failed` with the fallback host.
#### `FLUXER_EMAIL_FALLBACK_SMTP_HEADERS`
Default empty. Extra headers added to every message sent through the fallback provider, as a JSON object of header name to string value, such as `{"X-Example-Tracking":"off"}` for a provider that reads a header to turn off link tracking. Values are sent exactly as given, never encoded or folded. A name must be a valid header field name, a value must be printable ASCII without line breaks, and each header has to fit on one 998 character line. `From`, `To`, `Cc`, `Bcc`, `Subject`, `Reply-To`, `Message-ID`, `Date`, `Content-Type` and `MIME-Version` cannot be set here. Anything else fails startup with the reason. `.env.example` lists it commented out.
Only `api` and `worker` build the mail service, so recreating those two is enough after a change. Only `api` and `worker` build the mail service, so recreating those two is enough after a change.
## CAPTCHA ## CAPTCHA
+36
View File
@@ -330,6 +330,41 @@ function validateReplyToEmail(value: string): void {
const EMAIL_DOMAIN_PATTERN = const EMAIL_DOMAIN_PATTERN =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u; /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
const DEFAULT_SMTP_PORT = 587; const DEFAULT_SMTP_PORT = 587;
const SMTP_HEADER_NAME_PATTERN = /^[\x21-\x39\x3b-\x7e]+$/u;
const SMTP_HEADER_VALUE_PATTERN = /^[\x20-\x7e\t]*$/u;
const SMTP_HEADER_MAX_LINE_LENGTH = 998;
const CORE_SMTP_HEADER_NAMES = new Set([
'from',
'to',
'cc',
'bcc',
'subject',
'reply-to',
'message-id',
'date',
'content-type',
'mime-version',
]);
function validateSmtpHeaders(headers: unknown, envName: string): void {
if (headers === undefined) return;
if (!isConfigObject(headers)) throw new Error(`${envName} must be a JSON object of header names to strings`);
for (const [name, value] of Object.entries(headers)) {
if (!SMTP_HEADER_NAME_PATTERN.test(name)) {
throw new Error(`${envName} header name ${JSON.stringify(name)} is not a valid header field name`);
}
if (CORE_SMTP_HEADER_NAMES.has(name.toLowerCase())) {
throw new Error(`${envName} must not set the ${name} header`);
}
if (typeof value !== 'string') throw new Error(`${envName} value of ${name} must be a string`);
if (!SMTP_HEADER_VALUE_PATTERN.test(value)) {
throw new Error(`${envName} value of ${name} must be printable ASCII without line breaks`);
}
if (name.length + 2 + value.length > SMTP_HEADER_MAX_LINE_LENGTH) {
throw new Error(`${envName} header ${name} must fit on one ${SMTP_HEADER_MAX_LINE_LENGTH} character line`);
}
}
}
function normalizeEmailFallbackConfig(config: MasterConfig): void { function normalizeEmailFallbackConfig(config: MasterConfig): void {
const email = config.integrations.email; const email = config.integrations.email;
@@ -342,6 +377,7 @@ function normalizeEmailFallbackConfig(config: MasterConfig): void {
if (!smtp.password?.trim()) throw missing('FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD'); if (!smtp.password?.trim()) throw missing('FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD');
const port: unknown = smtp.port ?? DEFAULT_SMTP_PORT; const port: unknown = smtp.port ?? DEFAULT_SMTP_PORT;
assertIntegerInRange(port, 'FLUXER_EMAIL_FALLBACK_SMTP_PORT', 1, 65_535); assertIntegerInRange(port, 'FLUXER_EMAIL_FALLBACK_SMTP_PORT', 1, 65_535);
validateSmtpHeaders(smtp.headers, 'FLUXER_EMAIL_FALLBACK_SMTP_HEADERS');
const normalizedDomains = domains.map((domain) => domain.trim().toLowerCase()); const normalizedDomains = domains.map((domain) => domain.trim().toLowerCase());
for (const domain of normalizedDomains) { for (const domain of normalizedDomains) {
if (!EMAIL_DOMAIN_PATTERN.test(domain)) { if (!EMAIL_DOMAIN_PATTERN.test(domain)) {
+1 -1
View File
@@ -197,7 +197,7 @@ export interface MasterConfig {
smtp?: SmtpConfig; smtp?: SmtpConfig;
fallback?: { fallback?: {
domains: Array<string>; domains: Array<string>;
smtp: SmtpConfig; smtp: SmtpConfig & {headers?: Record<string, string>};
}; };
}; };
voice: { voice: {
@@ -540,6 +540,30 @@ describe('ConfigLoader', () => {
}, },
); );
test('loads the email fallback headers', async () => {
stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: '{"X-Tracking":"{\\"enable\\":0}"}'});
const config = await loadConfig();
expect(config.integrations.email.fallback?.smtp.headers).toEqual({'X-Tracking': '{"enable":0}'});
});
test.each([
{headers: '["X-Tracking"]', error: 'must be a JSON object'},
{headers: '{"X-Tracking":{"enable":0}}', error: 'value of X-Tracking must be a string'},
{headers: '{"X-Tracking":0}', error: 'value of X-Tracking must be a string'},
{headers: '{"X Tracking":"off"}', error: 'header name "X Tracking" is not a valid header field name'},
{headers: '{"X-Tracking:":"off"}', error: 'header name "X-Tracking:" is not a valid header field name'},
{headers: '{"X-Tracking\\r\\nBcc":"off"}', error: 'is not a valid header field name'},
{headers: '{"X-Tracking":"off\\r\\nBcc: [email protected]"}', error: 'must be printable ASCII without line breaks'},
{headers: '{"X-Tracking":"off\\n"}', error: 'must be printable ASCII without line breaks'},
{headers: `{"X-Tracking":"${'a'.repeat(990)}"}`, error: 'must fit on one 998 character line'},
...['From', 'to', 'Cc', 'BCC', 'Subject', 'Reply-To', 'Message-ID', 'Date', 'content-type', 'MIME-Version'].map(
(name) => ({headers: JSON.stringify({[name]: 'x'}), error: `must not set the ${name} header`}),
),
])('rejects the email fallback headers $headers', async ({headers, error}) => {
stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: headers});
await expect(loadConfig()).rejects.toThrow(error);
});
test('rejects an out of range email fallback port', async () => { test('rejects an out of range email fallback port', async () => {
stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_PORT: '70000'}); stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_PORT: '70000'});
await expect(loadConfig()).rejects.toThrow( await expect(loadConfig()).rejects.toThrow(
@@ -48,6 +48,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
FLUXER_EMAIL_REPLY_TO_EMAIL: blank, FLUXER_EMAIL_REPLY_TO_EMAIL: blank,
FLUXER_EMAIL_FALLBACK_DOMAINS: blank, FLUXER_EMAIL_FALLBACK_DOMAINS: blank,
FLUXER_EMAIL_FALLBACK_SMTP_HOST: blank, FLUXER_EMAIL_FALLBACK_SMTP_HOST: blank,
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: blank,
}), }),
).toEqual({}); ).toEqual({});
}); });
@@ -61,6 +62,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: 'apikey', FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: 'apikey',
FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: 'secret', FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: 'secret',
FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: 'STARTTLS', FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: 'STARTTLS',
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: '{"X-Tracking":"off"}',
}), }),
).toEqual({ ).toEqual({
integrations: { integrations: {
@@ -73,6 +75,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
username: 'apikey', username: 'apikey',
password: 'secret', password: 'secret',
tls_mode: 'starttls', tls_mode: 'starttls',
headers: {'X-Tracking': 'off'},
}, },
}, },
}, },
@@ -174,6 +174,10 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
path: ['integrations', 'email', 'fallback', 'smtp', 'tls_mode'], path: ['integrations', 'email', 'fallback', 'smtp', 'tls_mode'],
parse: parseSmtpTlsMode, parse: parseSmtpTlsMode,
}, },
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: {
path: ['integrations', 'email', 'fallback', 'smtp', 'headers'],
parse: parseJsonObject,
},
FLUXER_LIVEKIT_ENABLED: {path: ['integrations', 'voice', 'enabled'], parse: parseBoolean}, FLUXER_LIVEKIT_ENABLED: {path: ['integrations', 'voice', 'enabled'], parse: parseBoolean},
FLUXER_LIVEKIT_API_KEY: {path: ['integrations', 'voice', 'api_key']}, FLUXER_LIVEKIT_API_KEY: {path: ['integrations', 'voice', 'api_key']},
FLUXER_LIVEKIT_API_SECRET: {path: ['integrations', 'voice', 'api_secret']}, FLUXER_LIVEKIT_API_SECRET: {path: ['integrations', 'voice', 'api_secret']},