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);
}
}