Compare commits

...
Author SHA1 Message Date
HampusandGitHub 811341bc2f feat(email): configurable reply-to address (#3140) 2026-10-02 23:26:01 +02:00
16 changed files with 138 additions and 1 deletions
+1
View File
@@ -309,6 +309,7 @@ FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
FLUXER_EMAIL_FROM_NAME=Fluxer
#[email protected]
FLUXER_EMAIL_APP_BASE_URL=
FLUXER_EMAIL_SMTP_HOST=
FLUXER_EMAIL_SMTP_PORT=587
+1
View File
@@ -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:-}
@@ -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;
}
@@ -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: '[email protected]',
fromName: 'Fluxer',
appBaseUrl: 'https://example.com',
marketingBaseUrl: 'https://example.com',
};
async function sendWith(config: EmailConfig): Promise<EmailMessage> {
const sent: Array<EmailMessage> = [];
const provider: IEmailProvider = {
sendEmail: async (message) => {
sent.push(message);
return true;
},
};
const service = new EmailService(config, new EmailI18nService(), provider);
await expect(service.sendRegistrationApprovedEmail('[email protected]', '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: '[email protected]'});
expect(message.replyTo).toBe('[email protected]');
expect(message.from).toEqual({email: '[email protected]', 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');
});
});
@@ -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,
});
@@ -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: '[email protected]',
from: {email: '[email protected]', 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: '[email protected]'})).resolves.toBe(true);
expect(sendMail).toHaveBeenCalledWith({
to: '[email protected]',
from: 'Fluxer <[email protected]>',
replyTo: '[email protected]',
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');
});
});
@@ -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,
});
+1
View File
@@ -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
? {
+1
View File
@@ -155,6 +155,7 @@ export interface APIConfig {
webhookSecret?: string;
fromEmail: string;
fromName: string;
replyToEmail: string;
appBaseUrl: string;
smtp?: {
host: string;
@@ -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,
};
@@ -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 `[email protected]`, 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`.
+8
View File
@@ -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 [email protected]');
}
}
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);
+1
View File
@@ -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?: {
@@ -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: '[email protected]'});
const config = await loadConfig();
expect(config.integrations.email.reply_to_email).toBe('[email protected]');
});
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 <[email protected]>', '[email protected],[email protected]', ' [email protected]'])(
'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',
@@ -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: '[email protected]',
});
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: '[email protected]'},
},
});
});
@@ -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({});
});
@@ -148,6 +148,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
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']},