mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<NonNullable<typeof email.fallback>> = 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(
|
||||
|
||||
@@ -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<string>;
|
||||
smtp: SmtpConfig;
|
||||
};
|
||||
};
|
||||
voice: {
|
||||
|
||||
@@ -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', '[email protected]', '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');
|
||||
|
||||
@@ -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'}},
|
||||
|
||||
@@ -165,6 +165,15 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
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']},
|
||||
|
||||
Reference in new issue
Block a user