From 2d8d130be1839c61e31dae32975dfd522aa46b99 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 17 Oct 2026 04:16:08 +0200 Subject: [PATCH] feat(api): allow routing all email through fallback (#3509) --- .../pkgs/email/src/DomainRoutedEmailProvider.test.ts | 10 ++++++++++ .../pkgs/email/src/DomainRoutedEmailProvider.ts | 2 +- .../src/content/docs/operator/configuration.mdx | 2 +- packages/config/src/ConfigLoader.ts | 5 ++++- packages/config/src/__tests__/ConfigLoader.test.ts | 11 +++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.test.ts b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.test.ts index c4098bf74..08a9ad5ca 100644 --- a/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.test.ts +++ b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.test.ts @@ -30,6 +30,16 @@ describe('DomainRoutedEmailProvider', () => { expect(result).toBe(route === 'primary'); }); + it('sends every address through the fallback provider when the domains are *', async () => { + const primary = {sendEmail: vi.fn().mockResolvedValue(true)}; + const fallback = {sendEmail: vi.fn().mockResolvedValue(true)}; + const provider = new DomainRoutedEmailProvider(primary, fallback, ['*']); + await provider.sendEmail(message('user@example.com')); + await provider.sendEmail(message('user@gmail.com')); + expect(fallback.sendEmail).toHaveBeenCalledTimes(2); + expect(primary.sendEmail).not.toHaveBeenCalled(); + }); + it('never reaches either provider for a bounced address', async () => { const primary = {sendEmail: vi.fn().mockResolvedValue(true)}; const fallback = {sendEmail: vi.fn().mockResolvedValue(true)}; diff --git a/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.ts b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.ts index 9f0ec367d..f9b58574b 100644 --- a/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.ts +++ b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.ts @@ -21,7 +21,7 @@ export class DomainRoutedEmailProvider implements IEmailProvider { .slice(message.to.lastIndexOf('@') + 1) .trim() .toLowerCase(); - const useFallback = this.fallbackDomains.has(domain); + const useFallback = this.fallbackDomains.has('*') || this.fallbackDomains.has(domain); logger.debug({domain, provider: useFallback ? 'fallback' : 'primary'}, 'Routing email'); return (useFallback ? this.fallback : this.primary).sendEmail(message); } diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 3b638d15c..e03a8f72a 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -843,7 +843,7 @@ A second SMTP provider can take the mail for chosen recipient domains while ever #### `FLUXER_EMAIL_FALLBACK_DOMAINS` -Default empty. The recipient domains that go through the fallback provider, comma separated, such as `gmail.com,googlemail.com`. Matching ignores case and takes the part of the address after the last `@`. Only an exact match counts, so `gmail.com` leaves `mail.gmail.com` on the main provider. An entry that is not a bare domain fails startup. `.env.example` lists it commented out. +Default empty. The recipient domains that go through the fallback provider, comma separated, such as `gmail.com,googlemail.com`. Matching ignores case and takes the part of the address after the last `@`. Only an exact match counts, so `gmail.com` leaves `mail.gmail.com` on the main provider. Set it to `*` alone to send every email through the fallback provider. An entry that is not a bare domain or `*` fails startup. `.env.example` lists it commented out. #### `FLUXER_EMAIL_FALLBACK_SMTP_HOST` diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 884358f0d..f25b718c8 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -379,8 +379,11 @@ function normalizeEmailFallbackConfig(config: MasterConfig): void { 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()); + if (normalizedDomains.includes('*') && new Set(normalizedDomains).size > 1) { + throw new Error('FLUXER_EMAIL_FALLBACK_DOMAINS must be either * or a list of domains'); + } for (const domain of normalizedDomains) { - if (!EMAIL_DOMAIN_PATTERN.test(domain)) { + if (domain !== '*' && !EMAIL_DOMAIN_PATTERN.test(domain)) { throw new Error( `FLUXER_EMAIL_FALLBACK_DOMAINS entry ${JSON.stringify(domain)} must be a domain such as gmail.com`, ); diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index d9179dc3d..da384f505 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -540,6 +540,17 @@ describe('ConfigLoader', () => { }, ); + test('loads * as the email fallback domains', async () => { + stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_DOMAINS: ' * '}); + const config = await loadConfig(); + expect(config.integrations.email.fallback?.domains).toEqual(['*']); + }); + + test('rejects * mixed with email fallback domains', async () => { + stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_DOMAINS: '*,gmail.com'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_EMAIL_FALLBACK_DOMAINS must be either * or a list of domains'); + }); + test('loads the email fallback headers', async () => { stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_HEADERS: '{"X-Tracking":"{\\"enable\\":0}"}'}); const config = await loadConfig();