From 811341bc2fe2654b016e3e59b9c63342eff5a96d Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 2 Oct 2026 23:26:01 +0200 Subject: [PATCH] feat(email): configurable reply-to address (#3140) --- deploy/self-hosting/.env.example | 1 + deploy/self-hosting/docker-compose.yml | 1 + .../pkgs/email/src/EmailProviderTypes.ts | 2 + .../pkgs/email/src/EmailService.test.ts | 41 +++++++++++++++++ fluxer_api/pkgs/email/src/EmailService.ts | 1 + .../pkgs/email/src/SmtpEmailProvider.test.ts | 44 +++++++++++++++++++ .../pkgs/email/src/SmtpEmailProvider.ts | 1 + fluxer_api/src/api/Config.ts | 1 + fluxer_api/src/api/config/APIConfig.ts | 1 + .../src/api/middleware/ServiceSingletons.ts | 1 + .../content/docs/operator/configuration.mdx | 4 ++ packages/config/src/ConfigLoader.ts | 8 ++++ packages/config/src/MasterConfig.ts | 1 + .../config/src/__tests__/ConfigLoader.test.ts | 24 ++++++++++ .../__tests__/EnvironmentOverrides.test.ts | 7 ++- .../src/config_loader/EnvironmentOverrides.ts | 1 + 16 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 fluxer_api/pkgs/email/src/EmailService.test.ts create mode 100644 fluxer_api/pkgs/email/src/SmtpEmailProvider.test.ts diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index d533e9f6f..bcf2335b4 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -309,6 +309,7 @@ FLUXER_EMAIL_ENABLED=false FLUXER_EMAIL_PROVIDER=none FLUXER_EMAIL_FROM_EMAIL=noreply@example.com FLUXER_EMAIL_FROM_NAME=Fluxer +#FLUXER_EMAIL_REPLY_TO_EMAIL=support@example.com FLUXER_EMAIL_APP_BASE_URL= FLUXER_EMAIL_SMTP_HOST= FLUXER_EMAIL_SMTP_PORT=587 diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index abc0f4941..0ba00fbf4 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -113,6 +113,7 @@ x-fluxer-env: &fluxer-env FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-} FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost} FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-} + FLUXER_EMAIL_REPLY_TO_EMAIL: ${FLUXER_EMAIL_REPLY_TO_EMAIL:-} FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-} FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-} FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-} diff --git a/fluxer_api/pkgs/email/src/EmailProviderTypes.ts b/fluxer_api/pkgs/email/src/EmailProviderTypes.ts index ddb1869d5..e607e4328 100644 --- a/fluxer_api/pkgs/email/src/EmailProviderTypes.ts +++ b/fluxer_api/pkgs/email/src/EmailProviderTypes.ts @@ -4,6 +4,7 @@ export interface EmailConfig { enabled: boolean; fromEmail: string; fromName: string; + replyTo?: string | null; appBaseUrl: string; marketingBaseUrl: string; } @@ -14,6 +15,7 @@ export interface EmailMessage { email: string; name: string; }; + replyTo?: string; subject: string; text: string; } diff --git a/fluxer_api/pkgs/email/src/EmailService.test.ts b/fluxer_api/pkgs/email/src/EmailService.test.ts new file mode 100644 index 000000000..6f58065ea --- /dev/null +++ b/fluxer_api/pkgs/email/src/EmailService.test.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {EmailI18nService} from '@pkgs/email/src/EmailI18nService'; +import type {EmailConfig, EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes'; +import {EmailService} from '@pkgs/email/src/EmailService'; +import {describe, expect, it} from 'vitest'; + +const CONFIG: EmailConfig = { + enabled: true, + fromEmail: 'noreply@example.com', + fromName: 'Fluxer', + appBaseUrl: 'https://example.com', + marketingBaseUrl: 'https://example.com', +}; + +async function sendWith(config: EmailConfig): Promise { + const sent: Array = []; + const provider: IEmailProvider = { + sendEmail: async (message) => { + sent.push(message); + return true; + }, + }; + const service = new EmailService(config, new EmailI18nService(), provider); + await expect(service.sendRegistrationApprovedEmail('user@example.com', 'testuser', 'en-US')).resolves.toBe(true); + expect(sent).toHaveLength(1); + return sent[0]; +} + +describe('EmailService reply-to', () => { + it('sets the configured reply-to address on every message', async () => { + const message = await sendWith({...CONFIG, replyTo: 'support@example.com'}); + expect(message.replyTo).toBe('support@example.com'); + expect(message.from).toEqual({email: 'noreply@example.com', name: 'Fluxer'}); + }); + + it.each([undefined, null, ''])('omits the reply-to address when it is %j', async (replyTo) => { + const message = await sendWith({...CONFIG, replyTo}); + expect(message).not.toHaveProperty('replyTo'); + }); +}); diff --git a/fluxer_api/pkgs/email/src/EmailService.ts b/fluxer_api/pkgs/email/src/EmailService.ts index 40dff9da7..59deb11e4 100644 --- a/fluxer_api/pkgs/email/src/EmailService.ts +++ b/fluxer_api/pkgs/email/src/EmailService.ts @@ -375,6 +375,7 @@ export class EmailService implements IEmailService { return this.provider.sendEmail({ to: email, from: {email: this.config.fromEmail, name: this.config.fromName}, + ...(this.config.replyTo ? {replyTo: this.config.replyTo} : {}), subject, text: body, }); diff --git a/fluxer_api/pkgs/email/src/SmtpEmailProvider.test.ts b/fluxer_api/pkgs/email/src/SmtpEmailProvider.test.ts new file mode 100644 index 000000000..9f2a6f75b --- /dev/null +++ b/fluxer_api/pkgs/email/src/SmtpEmailProvider.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {SmtpEmailProvider} from '@pkgs/email/src/SmtpEmailProvider'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const {sendMail} = vi.hoisted(() => ({sendMail: vi.fn()})); + +vi.mock('nodemailer', () => ({ + default: {createTransport: () => ({sendMail, verify: vi.fn()})}, +})); + +const MESSAGE = { + to: 'user@example.com', + from: {email: 'noreply@example.com', name: 'Fluxer'}, + subject: 'Subject', + text: 'Body', +}; + +function createProvider(): SmtpEmailProvider { + return new SmtpEmailProvider({host: 'smtp.example.com', port: 587, username: 'user', password: 'pass'}); +} + +describe('SmtpEmailProvider', () => { + beforeEach(() => { + sendMail.mockReset(); + sendMail.mockResolvedValue({}); + }); + + it('passes the reply-to address to nodemailer', async () => { + await expect(createProvider().sendEmail({...MESSAGE, replyTo: 'support@example.com'})).resolves.toBe(true); + expect(sendMail).toHaveBeenCalledWith({ + to: 'user@example.com', + from: 'Fluxer ', + replyTo: 'support@example.com', + subject: 'Subject', + text: 'Body', + }); + }); + + it('omits the reply-to address when the message has none', async () => { + await expect(createProvider().sendEmail(MESSAGE)).resolves.toBe(true); + expect(sendMail.mock.calls[0][0]).not.toHaveProperty('replyTo'); + }); +}); diff --git a/fluxer_api/pkgs/email/src/SmtpEmailProvider.ts b/fluxer_api/pkgs/email/src/SmtpEmailProvider.ts index c52a171b4..58962b526 100644 --- a/fluxer_api/pkgs/email/src/SmtpEmailProvider.ts +++ b/fluxer_api/pkgs/email/src/SmtpEmailProvider.ts @@ -45,6 +45,7 @@ export class SmtpEmailProvider implements IEmailProvider { await this.transporter.sendMail({ to: message.to, from: `${message.from.name} <${message.from.email}>`, + ...(message.replyTo ? {replyTo: message.replyTo} : {}), subject: message.subject, text: message.text, }); diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 97ab5c51d..2ace88bd5 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -248,6 +248,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { webhookSecret: master.integrations.email.webhook_secret ?? undefined, fromEmail: master.integrations.email.from_email, fromName: master.integrations.email.from_name, + replyToEmail: master.integrations.email.reply_to_email, appBaseUrl: resolveEmailAppBaseUrl(master), smtp: master.integrations.email.smtp ? { diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 64eddf8f5..7681bbb50 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -155,6 +155,7 @@ export interface APIConfig { webhookSecret?: string; fromEmail: string; fromName: string; + replyToEmail: string; appBaseUrl: string; smtp?: { host: string; diff --git a/fluxer_api/src/api/middleware/ServiceSingletons.ts b/fluxer_api/src/api/middleware/ServiceSingletons.ts index 80867c926..46ebcdb69 100644 --- a/fluxer_api/src/api/middleware/ServiceSingletons.ts +++ b/fluxer_api/src/api/middleware/ServiceSingletons.ts @@ -187,6 +187,7 @@ function createEmailServiceForConfig( enabled: emailConfigSource.enabled, fromEmail: emailConfigSource.fromEmail, fromName: emailConfigSource.fromName, + replyTo: emailConfigSource.replyToEmail || null, appBaseUrl: emailConfigSource.appBaseUrl, marketingBaseUrl: Config.endpoints.marketing, }; diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index e249b1439..b5bb28a7d 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -730,6 +730,10 @@ The same conditions turn on a DNS check at registration. The check runs when ema `.env.example` `Fluxer`. The sender name. Default `Fluxer`, which an empty value also gives. Compose forwards it from `.env`. +#### `FLUXER_EMAIL_REPLY_TO_EMAIL` + +Default empty. The address replies go to, sent as the `Reply-To` header on every email. An empty value sends no `Reply-To` header. Must be a single bare address such as `support@example.com`, or the API fails at boot. The admin dashboard has no field for it. Compose forwards it from `.env`, and `.env.example` lists it commented out. + #### `FLUXER_EMAIL_APP_BASE_URL` `.env.example` empty. The base URL used in links. Must be http or https with no username, password, query, or fragment, or the API fails at boot. Falls back to the app endpoint. Compose forwards it from `.env`. diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 614bc8420..0a7b3c48d 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -163,6 +163,7 @@ function defaultConfig(): MasterConfig { provider: 'none', from_email: '', from_name: 'Fluxer', + reply_to_email: '', app_base_url: '', }, voice: { @@ -292,6 +293,12 @@ function requireString(value: string | undefined, envName: string): void { } } +function validateReplyToEmail(value: string): void { + if (value !== '' && !/^[^\s@<>,;"]+@[^\s@<>,;"]+$/.test(value)) { + throw new Error('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address such as support@example.com'); + } +} + function validateUploadRelaySecret(value: string): void { const trimmed = value.trim(); if (trimmed.length === 0) { @@ -566,6 +573,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig { validateStorageChangeFeedConfig(config); validateCachePurgeConfig(config); validateStoreBillingConfig(config); + validateReplyToEmail(config.integrations.email.reply_to_email); normalizeAppOriginAliases(config); assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000); assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000); diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 512a10051..70ee3b255 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -169,6 +169,7 @@ export interface MasterConfig { provider: 'smtp' | 'none'; from_email: string; from_name: string; + reply_to_email: string; app_base_url: string; webhook_secret?: string; smtp?: { diff --git a/packages/config/src/__tests__/ConfigLoader.test.ts b/packages/config/src/__tests__/ConfigLoader.test.ts index c2a24701b..63cf31b56 100644 --- a/packages/config/src/__tests__/ConfigLoader.test.ts +++ b/packages/config/src/__tests__/ConfigLoader.test.ts @@ -290,6 +290,30 @@ describe('ConfigLoader', () => { expect(config.services.api.storage_change_feed?.skip_buckets).toBeUndefined(); }); + test('reads the email reply-to address', async () => { + stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: 'support@example.com'}); + + const config = await loadConfig(); + + expect(config.integrations.email.reply_to_email).toBe('support@example.com'); + }); + + test('leaves the email reply-to address empty when unset or blank', async () => { + stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: ' '}); + + const config = await loadConfig(); + + expect(config.integrations.email.reply_to_email).toBe(''); + }); + + test.each(['support', 'Support ', 'a@example.com,b@example.com', ' support@example.com'])( + 'rejects %j as the email reply-to address', + async (value) => { + stubMinimalEnv({FLUXER_EMAIL_REPLY_TO_EMAIL: value}); + await expect(loadConfig()).rejects.toThrow('FLUXER_EMAIL_REPLY_TO_EMAIL must be a single email address'); + }, + ); + test('keeps explicit passkey relying party values', async () => { stubMinimalEnv({ FLUXER_BASE_DOMAIN: 'chat.example.com', diff --git a/packages/config/src/__tests__/EnvironmentOverrides.test.ts b/packages/config/src/__tests__/EnvironmentOverrides.test.ts index ae4e4371c..f35f402ea 100644 --- a/packages/config/src/__tests__/EnvironmentOverrides.test.ts +++ b/packages/config/src/__tests__/EnvironmentOverrides.test.ts @@ -52,6 +52,7 @@ describe('buildNamedFluxerEnvOverrides', () => { FLUXER_S3_FORCE_PATH_STYLE: 'true', FLUXER_AUTH_BLUESKY_KEYS: '[{"kid":"key-1","private_key_path":"/etc/fluxer/keys/bluesky.pem"}]', FLUXER_STRIPE_PRICE_MONTHLY_USD: 'price_monthly_usd', + FLUXER_EMAIL_REPLY_TO_EMAIL: 'support@example.com', }); expect(overrides).toMatchObject({ @@ -62,7 +63,10 @@ describe('buildNamedFluxerEnvOverrides', () => { bluesky: {keys: [{kid: 'key-1', private_key_path: '/etc/fluxer/keys/bluesky.pem'}]}, }, s3: {force_path_style: true}, - integrations: {stripe: {prices: {monthly_usd: 'price_monthly_usd'}}}, + integrations: { + stripe: {prices: {monthly_usd: 'price_monthly_usd'}}, + email: {reply_to_email: 'support@example.com'}, + }, }); }); @@ -77,6 +81,7 @@ describe('buildNamedFluxerEnvOverrides', () => { FLUXER_LIVEKIT_DEFAULT_REGION: blank, FLUXER_AUTH_BLUESKY_KEYS: blank, FLUXER_EMAIL_FROM_NAME: blank, + FLUXER_EMAIL_REPLY_TO_EMAIL: blank, }), ).toEqual({}); }); diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 7c4feba4f..715eb6886 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -148,6 +148,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_EMAIL_PROVIDER: {path: ['integrations', 'email', 'provider']}, FLUXER_EMAIL_FROM_EMAIL: {path: ['integrations', 'email', 'from_email']}, FLUXER_EMAIL_FROM_NAME: {path: ['integrations', 'email', 'from_name']}, + FLUXER_EMAIL_REPLY_TO_EMAIL: {path: ['integrations', 'email', 'reply_to_email']}, FLUXER_EMAIL_APP_BASE_URL: {path: ['integrations', 'email', 'app_base_url']}, FLUXER_EMAIL_WEBHOOK_SECRET: {path: ['integrations', 'email', 'webhook_secret']}, FLUXER_EMAIL_SMTP_HOST: {path: ['integrations', 'email', 'smtp', 'host']},