feat(api): add domain-routed fallback email provider (#3506)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-17 01:45:19 +02:00
1 parent c195e2b5de
commit 0b1ddc2695
15 files changed
+346 -30

No files matched your search

@@ -0,0 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
import type {EmailMessage} from '@pkgs/email/src/EmailProviderTypes';
import {EmailService} from '@pkgs/email/src/EmailService';
import {describe, expect, it, vi} from 'vitest';
function message(to: string): EmailMessage {
return {to, from: {email: '[email protected]', name: 'Example'}, subject: 'Hello', text: 'Body'};
}
describe('DomainRoutedEmailProvider', () => {
it.each([
{to: '[email protected]', route: 'fallback'},
{to: '[email protected]', route: 'fallback'},
{to: '[email protected]', route: 'fallback'},
{to: '[email protected]', route: 'primary'},
{to: '[email protected]', route: 'primary'},
{to: '[email protected]', route: 'primary'},
{to: '"[email protected]"@example.com', route: 'primary'},
])('sends $to through the $route provider', async ({to, route}) => {
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
const fallback = {sendEmail: vi.fn().mockResolvedValue(false)};
const provider = new DomainRoutedEmailProvider(primary, fallback, ['gmail.com', 'googlemail.com']);
const sent = message(to);
const result = await provider.sendEmail(sent);
const [chosen, other] = route === 'fallback' ? [fallback, primary] : [primary, fallback];
expect(chosen.sendEmail).toHaveBeenCalledWith(sent);
expect(other.sendEmail).not.toHaveBeenCalled();
expect(result).toBe(route === 'primary');
});
it('never reaches either provider for a bounced address', async () => {
const primary = {sendEmail: vi.fn().mockResolvedValue(true)};
const fallback = {sendEmail: vi.fn().mockResolvedValue(true)};
const service = new EmailService(
{
enabled: true,
fromEmail: '[email protected]',
fromName: 'Example',
appBaseUrl: 'https://example.com',
appealsEmail: null,
safetyEmail: null,
supportEmail: null,
productName: 'Example',
},
new DomainRoutedEmailProvider(primary, fallback, ['gmail.com']),
{isEmailBounced: async () => true},
);
expect(await service.sendPasswordResetEmail('[email protected]', 'user', 'token')).toBe(false);
expect(primary.sendEmail).not.toHaveBeenCalled();
expect(fallback.sendEmail).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createLogger} from '@fluxer/logger/src/Logger';
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
const logger = createLogger('@pkgs/email/src/DomainRoutedEmailProvider');
export class DomainRoutedEmailProvider implements IEmailProvider {
private readonly primary: IEmailProvider;
private readonly fallback: IEmailProvider;
private readonly fallbackDomains: ReadonlySet<string>;
constructor(primary: IEmailProvider, fallback: IEmailProvider, fallbackDomains: ReadonlyArray<string>) {
this.primary = primary;
this.fallback = fallback;
this.fallbackDomains = new Set(fallbackDomains);
}
async sendEmail(message: EmailMessage): Promise<boolean> {
const domain = message.to
.slice(message.to.lastIndexOf('@') + 1)
.trim()
.toLowerCase();
const useFallback = this.fallbackDomains.has(domain);
logger.debug({domain, provider: useFallback ? 'fallback' : 'primary'}, 'Routing email');
return (useFallback ? this.fallback : this.primary).sendEmail(message);
}
}
+16 -7
View File
@@ -39,6 +39,18 @@ function resolveEmailAppBaseUrl(master: MasterConfig): string {
}
}
function toSmtpConfig(
smtp: NonNullable<MasterConfig['integrations']['email']['smtp']>,
): NonNullable<APIConfig['email']['smtp']> {
return {
host: smtp.host,
port: smtp.port,
username: smtp.username,
password: smtp.password,
tlsMode: smtp.tls_mode ?? smtpTlsModeFromSecure(smtp.secure),
};
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
@@ -291,14 +303,11 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
fromName: master.integrations.email.from_name,
replyToEmail: master.integrations.email.reply_to_email,
appBaseUrl: resolveEmailAppBaseUrl(master),
smtp: master.integrations.email.smtp
smtp: master.integrations.email.smtp ? toSmtpConfig(master.integrations.email.smtp) : undefined,
fallback: master.integrations.email.fallback
? {
host: master.integrations.email.smtp.host,
port: master.integrations.email.smtp.port,
username: master.integrations.email.smtp.username,
password: master.integrations.email.smtp.password,
tlsMode:
master.integrations.email.smtp.tls_mode ?? smtpTlsModeFromSecure(master.integrations.email.smtp.secure),
domains: master.integrations.email.fallback.domains,
smtp: toSmtpConfig(master.integrations.email.fallback.smtp),
}
: undefined,
},
+12 -6
View File
@@ -19,6 +19,14 @@ export interface TrustedCallerConfig {
buckets: Array<string>;
}
interface SmtpConfig {
host: string;
port: number;
username: string;
password: string;
tlsMode: SmtpTlsMode | null;
}
interface APICachePurgeTarget {
endpoint: string;
token: string;
@@ -162,12 +170,10 @@ export interface APIConfig {
fromName: string;
replyToEmail: string;
appBaseUrl: string;
smtp?: {
host: string;
port: number;
username: string;
password: string;
tlsMode: SmtpTlsMode | null;
smtp?: SmtpConfig;
fallback?: {
domains: Array<string>;
smtp: SmtpConfig;
};
};
blocklistFeeds: {
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig} from '@app/api/config/APIConfig';
import {createEmailProvider} from '@app/api/email/EmailProviderFactory';
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
import {describe, expect, it} from 'vitest';
const smtp = {host: 'smtp.example.com', port: 587, username: 'user', password: 'pass', tlsMode: null};
function emailConfig(overrides: Partial<APIConfig['email']> = {}): APIConfig['email'] {
return {
enabled: true,
provider: 'smtp',
fromEmail: '[email protected]',
fromName: 'Example',
replyToEmail: '',
appBaseUrl: 'https://example.com',
smtp,
...overrides,
};
}
describe('createEmailProvider', () => {
it('returns the plain SMTP provider without a fallback', () => {
expect(createEmailProvider(emailConfig())).toBeInstanceOf(SmtpEmailProvider);
});
it('routes by domain when a fallback is set', () => {
const provider = createEmailProvider(
emailConfig({fallback: {domains: ['gmail.com'], smtp: {...smtp, host: 'smtp.sendgrid.net'}}}),
);
expect(provider).toBeInstanceOf(DomainRoutedEmailProvider);
});
it.each([{enabled: false}, {provider: 'none' as const}, {smtp: undefined}])(
'returns no provider for %j even with a fallback',
(overrides) => {
const fallback = {domains: ['gmail.com'], smtp};
expect(createEmailProvider(emailConfig({fallback, ...overrides}))).toBeNull();
},
);
});
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig} from '@app/api/config/APIConfig';
import {DomainRoutedEmailProvider} from '@pkgs/email/src/DomainRoutedEmailProvider';
import type {IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider';
@@ -9,16 +10,14 @@ export function createEmailProvider(emailConfig: APIConfig['email']): IEmailProv
return null;
}
switch (emailConfig.provider) {
case 'smtp':
return emailConfig.smtp
? new SmtpEmailProvider({
host: emailConfig.smtp.host,
port: emailConfig.smtp.port,
username: emailConfig.smtp.username,
password: emailConfig.smtp.password,
tlsMode: emailConfig.smtp.tlsMode,
})
: null;
case 'smtp': {
if (!emailConfig.smtp) return null;
const primary = new SmtpEmailProvider(emailConfig.smtp);
const {fallback} = emailConfig;
return fallback
? new DomainRoutedEmailProvider(primary, new SmtpEmailProvider(fallback.smtp), fallback.domains)
: primary;
}
default:
return null;
}