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

+36
View File
@@ -330,6 +330,41 @@ function validateReplyToEmail(value: string): void {
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;
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 {
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');
const port: unknown = smtp.port ?? DEFAULT_SMTP_PORT;
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());
for (const domain of normalizedDomains) {
if (!EMAIL_DOMAIN_PATTERN.test(domain)) {
+1 -1
View File
@@ -197,7 +197,7 @@ export interface MasterConfig {
smtp?: SmtpConfig;
fallback?: {
domains: Array<string>;
smtp: SmtpConfig;
smtp: SmtpConfig & {headers?: Record<string, string>};
};
};
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 () => {
stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_PORT: '70000'});
await expect(loadConfig()).rejects.toThrow(
@@ -48,6 +48,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
FLUXER_EMAIL_REPLY_TO_EMAIL: blank,
FLUXER_EMAIL_FALLBACK_DOMAINS: blank,
FLUXER_EMAIL_FALLBACK_SMTP_HOST: blank,
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: blank,
}),
).toEqual({});
});
@@ -61,6 +62,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: 'apikey',
FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: 'secret',
FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: 'STARTTLS',
FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: '{"X-Tracking":"off"}',
}),
).toEqual({
integrations: {
@@ -73,6 +75,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
username: 'apikey',
password: 'secret',
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'],
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_API_KEY: {path: ['integrations', 'voice', 'api_key']},
FLUXER_LIVEKIT_API_SECRET: {path: ['integrations', 'voice', 'api_secret']},