mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
feat(api): add domain-routed fallback email provider (#3506)
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user