diff --git a/deploy/helm/fluxer/values.yaml b/deploy/helm/fluxer/values.yaml index 32cd7a5b0..0c03c66e9 100644 --- a/deploy/helm/fluxer/values.yaml +++ b/deploy/helm/fluxer/values.yaml @@ -130,6 +130,8 @@ secrets: # FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64: "" # SMTP password for outgoing email. # FLUXER_EMAIL_SMTP_PASSWORD: "" + # SMTP password of the email fallback provider. + # FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: "" # Secret that authenticates email provider webhooks. # FLUXER_EMAIL_WEBHOOK_SECRET: "" # Stripe secret key. diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index a492a277c..19e0a37d8 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -325,6 +325,15 @@ FLUXER_EMAIL_SMTP_TLS_MODE= # The older switch, read only while the mode above is empty. true is implicit # and false is opportunistic. #FLUXER_EMAIL_SMTP_SECURE=false +# A second SMTP provider for mail to the listed recipient domains, comma +# separated and matched exactly. Everything else goes through the provider +# above. The port falls back to 587 and the mode works like the one above. +#FLUXER_EMAIL_FALLBACK_DOMAINS=gmail.com,googlemail.com +#FLUXER_EMAIL_FALLBACK_SMTP_HOST= +#FLUXER_EMAIL_FALLBACK_SMTP_PORT=587 +#FLUXER_EMAIL_FALLBACK_SMTP_USERNAME= +#FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD= +#FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE= #FLUXER_EMAIL_WEBHOOK_SECRET= FLUXER_DISCOVERY_ENABLED=true diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index cd3c7cf1f..c5a857fa7 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -126,6 +126,12 @@ x-fluxer-env: &fluxer-env FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-} FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-} FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-} + FLUXER_EMAIL_FALLBACK_DOMAINS: ${FLUXER_EMAIL_FALLBACK_DOMAINS:-} + FLUXER_EMAIL_FALLBACK_SMTP_HOST: ${FLUXER_EMAIL_FALLBACK_SMTP_HOST:-} + FLUXER_EMAIL_FALLBACK_SMTP_PORT: ${FLUXER_EMAIL_FALLBACK_SMTP_PORT:-} + FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: ${FLUXER_EMAIL_FALLBACK_SMTP_USERNAME:-} + FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: ${FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD:-} + FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: ${FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE:-} FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-} FLUXER_STRIPE_SECRET_KEY: ${FLUXER_STRIPE_SECRET_KEY:-} diff --git a/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.test.ts b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.test.ts new file mode 100644 index 000000000..c4098bf74 --- /dev/null +++ b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.test.ts @@ -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: 'noreply@example.com', name: 'Example'}, subject: 'Hello', text: 'Body'}; +} + +describe('DomainRoutedEmailProvider', () => { + it.each([ + {to: 'user@gmail.com', route: 'fallback'}, + {to: 'user@GOOGLEMAIL.COM', route: 'fallback'}, + {to: 'User.Name+tag@Gmail.Com', route: 'fallback'}, + {to: 'user@mail.gmail.com', route: 'primary'}, + {to: 'user@gmail.com.example.org', route: 'primary'}, + {to: 'user@example.com', route: 'primary'}, + {to: '"odd@gmail.com"@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: 'noreply@example.com', + 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('user@gmail.com', 'user', 'token')).toBe(false); + expect(primary.sendEmail).not.toHaveBeenCalled(); + expect(fallback.sendEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.ts b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.ts new file mode 100644 index 000000000..9f0ec367d --- /dev/null +++ b/fluxer_api/pkgs/email/src/DomainRoutedEmailProvider.ts @@ -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; + + constructor(primary: IEmailProvider, fallback: IEmailProvider, fallbackDomains: ReadonlyArray) { + this.primary = primary; + this.fallback = fallback; + this.fallbackDomains = new Set(fallbackDomains); + } + + async sendEmail(message: EmailMessage): Promise { + 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); + } +} diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index bfdd9efd3..25bf058c9 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -39,6 +39,18 @@ function resolveEmailAppBaseUrl(master: MasterConfig): string { } } +function toSmtpConfig( + smtp: NonNullable, +): NonNullable { + 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, }, diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 63b96b048..dc31c5862 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -19,6 +19,14 @@ export interface TrustedCallerConfig { buckets: Array; } +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; + smtp: SmtpConfig; }; }; blocklistFeeds: { diff --git a/fluxer_api/src/api/email/EmailProviderFactory.test.ts b/fluxer_api/src/api/email/EmailProviderFactory.test.ts new file mode 100644 index 000000000..4f3d7b086 --- /dev/null +++ b/fluxer_api/src/api/email/EmailProviderFactory.test.ts @@ -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'] { + return { + enabled: true, + provider: 'smtp', + fromEmail: 'noreply@example.com', + 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(); + }, + ); +}); diff --git a/fluxer_api/src/api/email/EmailProviderFactory.ts b/fluxer_api/src/api/email/EmailProviderFactory.ts index ac73d5e1a..70b42168e 100644 --- a/fluxer_api/src/api/email/EmailProviderFactory.ts +++ b/fluxer_api/src/api/email/EmailProviderFactory.ts @@ -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; } diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 0c10b989d..94bd57802 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -839,6 +839,32 @@ The API reads `FLUXER_EMAIL_WEBHOOK_SECRET` for inbound delivery webhooks. `.env The API registers `POST /webhooks/sweego` on every deployment. The route authenticates the Standard Webhooks header triple `webhook-id`, `webhook-timestamp`, and `webhook-signature` against that secret, and answers 404 `Email not enabled` while email is off. The route sits outside the client-IP exempt list that covers `/webhooks/livekit`, so give the provider a delivery URL that goes through the edge, `https://chat.example.com/api/webhooks/sweego`. +A second SMTP provider can take the mail for chosen recipient domains while everything else keeps going through the one above. It is off by default. Setting any fallback variable turns it on, and from then on the domains, host, username and password are all required, or startup fails with the name of the one that is missing. The sender, `Reply-To` header and bounced-address check are the same for both providers. Email still has to be on with the main SMTP provider complete, because the fallback never sends on its own. The admin dashboard has no fields for it, and the SMTP test only checks the main provider. Bounce webhooks come only from the provider behind `/webhooks/sweego`, so a hard bounce at the fallback provider does not mark the address as bounced. + +#### `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. + +#### `FLUXER_EMAIL_FALLBACK_SMTP_HOST` + +Default empty. The fallback SMTP host. Required when the domains are set. + +#### `FLUXER_EMAIL_FALLBACK_SMTP_PORT` + +Default `587`. The fallback SMTP port. + +#### `FLUXER_EMAIL_FALLBACK_SMTP_USERNAME` + +Default empty. The fallback SMTP login. Required when the domains are set. + +#### `FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD` + +Default empty. The fallback SMTP password. Required when the domains are set. + +#### `FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE` + +Default empty. How the fallback connection is encrypted, with the same four values as `FLUXER_EMAIL_SMTP_TLS_MODE`. An empty value picks `implicit` on port 465 and `starttls` on every other port. A failed send through the fallback is logged as `SMTP send failed` with the fallback host. + Only `api` and `worker` build the mail service, so recreating those two is enough after a change. ## CAPTCHA diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 928cb7750..9de80c1eb 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -327,6 +327,32 @@ 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; + +function normalizeEmailFallbackConfig(config: MasterConfig): void { + const email = config.integrations.email; + if (!email.fallback) return; + const {domains, smtp}: Partial> = email.fallback; + const missing = (envName: string) => new Error(`${envName} is required when an email fallback provider is set`); + if (!domains?.length) throw missing('FLUXER_EMAIL_FALLBACK_DOMAINS'); + if (!smtp?.host?.trim()) throw missing('FLUXER_EMAIL_FALLBACK_SMTP_HOST'); + if (!smtp.username?.trim()) throw missing('FLUXER_EMAIL_FALLBACK_SMTP_USERNAME'); + 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); + const normalizedDomains = domains.map((domain) => domain.trim().toLowerCase()); + for (const domain of normalizedDomains) { + if (!EMAIL_DOMAIN_PATTERN.test(domain)) { + throw new Error( + `FLUXER_EMAIL_FALLBACK_DOMAINS entry ${JSON.stringify(domain)} must be a domain such as gmail.com`, + ); + } + } + email.fallback = {domains: [...new Set(normalizedDomains)], smtp: {...smtp, port}}; +} + function validateUploadRelaySecret(value: string): void { const trimmed = value.trim(); if (trimmed.length === 0) { @@ -626,6 +652,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig { validateCachePurgeConfig(config); validateStoreBillingConfig(config); validateReplyToEmail(config.integrations.email.reply_to_email); + normalizeEmailFallbackConfig(config); normalizeAppOriginAliases(config); assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000); assertIntegerInRange( diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 2a73a956a..81c70bf4c 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -15,6 +15,15 @@ export const TAG_STYLE_NAMES = ['none', 'random'] as const; type TagStyleName = (typeof TAG_STYLE_NAMES)[number]; type SmtpTlsModeName = 'implicit' | 'starttls' | 'opportunistic' | 'none'; +interface SmtpConfig { + host: string; + port: number; + username: string; + password: string; + tls_mode?: SmtpTlsModeName; + secure?: boolean; +} + interface InstanceBrandingConfig { product_name: string; icon_url?: string; @@ -185,13 +194,10 @@ export interface MasterConfig { reply_to_email: string; app_base_url: string; webhook_secret?: string; - smtp?: { - host: string; - port: number; - username: string; - password: string; - tls_mode?: SmtpTlsModeName; - secure?: boolean; + smtp?: SmtpConfig; + fallback?: { + domains: Array; + smtp: SmtpConfig; }; }; voice: { diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index f6940b70c..ff2f734e5 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -485,6 +485,68 @@ describe('ConfigLoader', () => { ); }); + const emailFallbackEnv = { + FLUXER_EMAIL_FALLBACK_DOMAINS: ' Gmail.com, googlemail.com ,gmail.com', + FLUXER_EMAIL_FALLBACK_SMTP_HOST: 'smtp.sendgrid.net', + FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: 'apikey', + FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: 'secret', + }; + + test('leaves the email fallback unset by default', async () => { + stubMinimalEnv(); + const config = await loadConfig(); + expect(config.integrations.email.fallback).toBeUndefined(); + }); + + test('loads the email fallback with normalized domains and port 587', async () => { + stubMinimalEnv(emailFallbackEnv); + const config = await loadConfig(); + expect(config.integrations.email.fallback).toEqual({ + domains: ['gmail.com', 'googlemail.com'], + smtp: {host: 'smtp.sendgrid.net', port: 587, username: 'apikey', password: 'secret'}, + }); + }); + + test('loads the email fallback port and TLS mode', async () => { + stubMinimalEnv({ + ...emailFallbackEnv, + FLUXER_EMAIL_FALLBACK_SMTP_PORT: '465', + FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: 'implicit', + }); + const config = await loadConfig(); + expect(config.integrations.email.fallback?.smtp).toMatchObject({port: 465, tls_mode: 'implicit'}); + }); + + test.each([ + 'FLUXER_EMAIL_FALLBACK_DOMAINS', + 'FLUXER_EMAIL_FALLBACK_SMTP_HOST', + 'FLUXER_EMAIL_FALLBACK_SMTP_USERNAME', + 'FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD', + ] as const)('rejects an email fallback without %s', async (missing) => { + stubMinimalEnv({...emailFallbackEnv, [missing]: ''}); + await expect(loadConfig()).rejects.toThrow(`${missing} is required when an email fallback provider is set`); + }); + + test('rejects email fallback domains without an SMTP provider', async () => { + stubMinimalEnv({FLUXER_EMAIL_FALLBACK_DOMAINS: 'gmail.com'}); + await expect(loadConfig()).rejects.toThrow('FLUXER_EMAIL_FALLBACK_SMTP_HOST is required'); + }); + + test.each(['@gmail.com', 'user@gmail.com', 'gmail', '*.gmail.com', 'gmail.com:25', '-gmail.com', 'gmail..com'])( + 'rejects the email fallback domain %j', + async (domain) => { + stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_DOMAINS: domain}); + await expect(loadConfig()).rejects.toThrow('FLUXER_EMAIL_FALLBACK_DOMAINS entry'); + }, + ); + + test('rejects an out of range email fallback port', async () => { + stubMinimalEnv({...emailFallbackEnv, FLUXER_EMAIL_FALLBACK_SMTP_PORT: '70000'}); + await expect(loadConfig()).rejects.toThrow( + 'FLUXER_EMAIL_FALLBACK_SMTP_PORT must be an integer between 1 and 65535', + ); + }); + test.each(['sequential', 'zero_first'])('rejects the tag style %j', async (raw) => { stubMinimalEnv({FLUXER_TAG_STYLE: raw}); await expect(loadConfig()).rejects.toThrow('FLUXER_TAG_STYLE must be none or random'); diff --git a/packages/config/src/__tests__/EnvironmentOverrides.test.ts b/packages/config/src/__tests__/EnvironmentOverrides.test.ts index f51eb1bb9..da5fc09fc 100644 --- a/packages/config/src/__tests__/EnvironmentOverrides.test.ts +++ b/packages/config/src/__tests__/EnvironmentOverrides.test.ts @@ -46,10 +46,40 @@ describe('buildNamedFluxerEnvOverrides', () => { FLUXER_AUTH_BLUESKY_KEYS: blank, FLUXER_EMAIL_FROM_NAME: blank, FLUXER_EMAIL_REPLY_TO_EMAIL: blank, + FLUXER_EMAIL_FALLBACK_DOMAINS: blank, + FLUXER_EMAIL_FALLBACK_SMTP_HOST: blank, }), ).toEqual({}); }); + test('maps the email fallback variables onto the fallback section', () => { + expect( + buildNamedFluxerEnvOverrides({ + FLUXER_EMAIL_FALLBACK_DOMAINS: 'gmail.com, googlemail.com', + FLUXER_EMAIL_FALLBACK_SMTP_HOST: 'smtp.sendgrid.net', + FLUXER_EMAIL_FALLBACK_SMTP_PORT: '587', + FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: 'apikey', + FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: 'secret', + FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: 'STARTTLS', + }), + ).toEqual({ + integrations: { + email: { + fallback: { + domains: ['gmail.com', 'googlemail.com'], + smtp: { + host: 'smtp.sendgrid.net', + port: 587, + username: 'apikey', + password: 'secret', + tls_mode: 'starttls', + }, + }, + }, + }, + }); + }); + test('maps the setup token onto the instance setup section', () => { expect(buildNamedFluxerEnvOverrides({FLUXER_SETUP_TOKEN: 'a-setup-token-of-at-least-32-characters'})).toEqual({ instance: {setup: {token: 'a-setup-token-of-at-least-32-characters'}}, diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index f49d3f3ea..023b97fc4 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -165,6 +165,15 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_EMAIL_SMTP_PASSWORD: {path: ['integrations', 'email', 'smtp', 'password']}, FLUXER_EMAIL_SMTP_TLS_MODE: {path: ['integrations', 'email', 'smtp', 'tls_mode'], parse: parseSmtpTlsMode}, FLUXER_EMAIL_SMTP_SECURE: {path: ['integrations', 'email', 'smtp', 'secure'], parse: parseBoolean}, + FLUXER_EMAIL_FALLBACK_DOMAINS: {path: ['integrations', 'email', 'fallback', 'domains'], parse: parseCsv}, + FLUXER_EMAIL_FALLBACK_SMTP_HOST: {path: ['integrations', 'email', 'fallback', 'smtp', 'host']}, + FLUXER_EMAIL_FALLBACK_SMTP_PORT: {path: ['integrations', 'email', 'fallback', 'smtp', 'port'], parse: parseInteger}, + FLUXER_EMAIL_FALLBACK_SMTP_USERNAME: {path: ['integrations', 'email', 'fallback', 'smtp', 'username']}, + FLUXER_EMAIL_FALLBACK_SMTP_PASSWORD: {path: ['integrations', 'email', 'fallback', 'smtp', 'password']}, + FLUXER_EMAIL_FALLBACK_SMTP_TLS_MODE: { + path: ['integrations', 'email', 'fallback', 'smtp', 'tls_mode'], + parse: parseSmtpTlsMode, + }, 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']},