mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(email): configurable reply-to address (#3140)
This commit is contained in:
@@ -309,6 +309,7 @@ FLUXER_EMAIL_ENABLED=false
|
|||||||
FLUXER_EMAIL_PROVIDER=none
|
FLUXER_EMAIL_PROVIDER=none
|
||||||
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
FLUXER_EMAIL_FROM_EMAIL=[email protected]
|
||||||
FLUXER_EMAIL_FROM_NAME=Fluxer
|
FLUXER_EMAIL_FROM_NAME=Fluxer
|
||||||
|
#[email protected]
|
||||||
FLUXER_EMAIL_APP_BASE_URL=
|
FLUXER_EMAIL_APP_BASE_URL=
|
||||||
FLUXER_EMAIL_SMTP_HOST=
|
FLUXER_EMAIL_SMTP_HOST=
|
||||||
FLUXER_EMAIL_SMTP_PORT=587
|
FLUXER_EMAIL_SMTP_PORT=587
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ x-fluxer-env: &fluxer-env
|
|||||||
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-}
|
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-}
|
||||||
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
|
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
|
||||||
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-}
|
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_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
|
||||||
FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-}
|
FLUXER_EMAIL_WEBHOOK_SECRET: ${FLUXER_EMAIL_WEBHOOK_SECRET:-}
|
||||||
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
|
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface EmailConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
fromEmail: string;
|
fromEmail: string;
|
||||||
fromName: string;
|
fromName: string;
|
||||||
|
replyTo?: string | null;
|
||||||
appBaseUrl: string;
|
appBaseUrl: string;
|
||||||
marketingBaseUrl: string;
|
marketingBaseUrl: string;
|
||||||
}
|
}
|
||||||
@@ -14,6 +15,7 @@ export interface EmailMessage {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
replyTo?: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
text: 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({
|
return this.provider.sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
from: {email: this.config.fromEmail, name: this.config.fromName},
|
from: {email: this.config.fromEmail, name: this.config.fromName},
|
||||||
|
...(this.config.replyTo ? {replyTo: this.config.replyTo} : {}),
|
||||||
subject,
|
subject,
|
||||||
text: body,
|
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({
|
await this.transporter.sendMail({
|
||||||
to: message.to,
|
to: message.to,
|
||||||
from: `${message.from.name} <${message.from.email}>`,
|
from: `${message.from.name} <${message.from.email}>`,
|
||||||
|
...(message.replyTo ? {replyTo: message.replyTo} : {}),
|
||||||
subject: message.subject,
|
subject: message.subject,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
|||||||
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
|
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
|
||||||
fromEmail: master.integrations.email.from_email,
|
fromEmail: master.integrations.email.from_email,
|
||||||
fromName: master.integrations.email.from_name,
|
fromName: master.integrations.email.from_name,
|
||||||
|
replyToEmail: master.integrations.email.reply_to_email,
|
||||||
appBaseUrl: resolveEmailAppBaseUrl(master),
|
appBaseUrl: resolveEmailAppBaseUrl(master),
|
||||||
smtp: master.integrations.email.smtp
|
smtp: master.integrations.email.smtp
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export interface APIConfig {
|
|||||||
webhookSecret?: string;
|
webhookSecret?: string;
|
||||||
fromEmail: string;
|
fromEmail: string;
|
||||||
fromName: string;
|
fromName: string;
|
||||||
|
replyToEmail: string;
|
||||||
appBaseUrl: string;
|
appBaseUrl: string;
|
||||||
smtp?: {
|
smtp?: {
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ function createEmailServiceForConfig(
|
|||||||
enabled: emailConfigSource.enabled,
|
enabled: emailConfigSource.enabled,
|
||||||
fromEmail: emailConfigSource.fromEmail,
|
fromEmail: emailConfigSource.fromEmail,
|
||||||
fromName: emailConfigSource.fromName,
|
fromName: emailConfigSource.fromName,
|
||||||
|
replyTo: emailConfigSource.replyToEmail || null,
|
||||||
appBaseUrl: emailConfigSource.appBaseUrl,
|
appBaseUrl: emailConfigSource.appBaseUrl,
|
||||||
marketingBaseUrl: Config.endpoints.marketing,
|
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`.
|
`.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`
|
#### `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`.
|
`.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`.
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ function defaultConfig(): MasterConfig {
|
|||||||
provider: 'none',
|
provider: 'none',
|
||||||
from_email: '',
|
from_email: '',
|
||||||
from_name: 'Fluxer',
|
from_name: 'Fluxer',
|
||||||
|
reply_to_email: '',
|
||||||
app_base_url: '',
|
app_base_url: '',
|
||||||
},
|
},
|
||||||
voice: {
|
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 {
|
function validateUploadRelaySecret(value: string): void {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
@@ -566,6 +573,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
|
|||||||
validateStorageChangeFeedConfig(config);
|
validateStorageChangeFeedConfig(config);
|
||||||
validateCachePurgeConfig(config);
|
validateCachePurgeConfig(config);
|
||||||
validateStoreBillingConfig(config);
|
validateStoreBillingConfig(config);
|
||||||
|
validateReplyToEmail(config.integrations.email.reply_to_email);
|
||||||
normalizeAppOriginAliases(config);
|
normalizeAppOriginAliases(config);
|
||||||
assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000);
|
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);
|
assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000);
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export interface MasterConfig {
|
|||||||
provider: 'smtp' | 'none';
|
provider: 'smtp' | 'none';
|
||||||
from_email: string;
|
from_email: string;
|
||||||
from_name: string;
|
from_name: string;
|
||||||
|
reply_to_email: string;
|
||||||
app_base_url: string;
|
app_base_url: string;
|
||||||
webhook_secret?: string;
|
webhook_secret?: string;
|
||||||
smtp?: {
|
smtp?: {
|
||||||
|
|||||||
@@ -290,6 +290,30 @@ describe('ConfigLoader', () => {
|
|||||||
expect(config.services.api.storage_change_feed?.skip_buckets).toBeUndefined();
|
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 () => {
|
test('keeps explicit passkey relying party values', async () => {
|
||||||
stubMinimalEnv({
|
stubMinimalEnv({
|
||||||
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
FLUXER_BASE_DOMAIN: 'chat.example.com',
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
|||||||
FLUXER_S3_FORCE_PATH_STYLE: 'true',
|
FLUXER_S3_FORCE_PATH_STYLE: 'true',
|
||||||
FLUXER_AUTH_BLUESKY_KEYS: '[{"kid":"key-1","private_key_path":"/etc/fluxer/keys/bluesky.pem"}]',
|
FLUXER_AUTH_BLUESKY_KEYS: '[{"kid":"key-1","private_key_path":"/etc/fluxer/keys/bluesky.pem"}]',
|
||||||
FLUXER_STRIPE_PRICE_MONTHLY_USD: 'price_monthly_usd',
|
FLUXER_STRIPE_PRICE_MONTHLY_USD: 'price_monthly_usd',
|
||||||
|
FLUXER_EMAIL_REPLY_TO_EMAIL: '[email protected]',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(overrides).toMatchObject({
|
expect(overrides).toMatchObject({
|
||||||
@@ -62,7 +63,10 @@ describe('buildNamedFluxerEnvOverrides', () => {
|
|||||||
bluesky: {keys: [{kid: 'key-1', private_key_path: '/etc/fluxer/keys/bluesky.pem'}]},
|
bluesky: {keys: [{kid: 'key-1', private_key_path: '/etc/fluxer/keys/bluesky.pem'}]},
|
||||||
},
|
},
|
||||||
s3: {force_path_style: true},
|
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_LIVEKIT_DEFAULT_REGION: blank,
|
||||||
FLUXER_AUTH_BLUESKY_KEYS: blank,
|
FLUXER_AUTH_BLUESKY_KEYS: blank,
|
||||||
FLUXER_EMAIL_FROM_NAME: blank,
|
FLUXER_EMAIL_FROM_NAME: blank,
|
||||||
|
FLUXER_EMAIL_REPLY_TO_EMAIL: blank,
|
||||||
}),
|
}),
|
||||||
).toEqual({});
|
).toEqual({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
|||||||
FLUXER_EMAIL_PROVIDER: {path: ['integrations', 'email', 'provider']},
|
FLUXER_EMAIL_PROVIDER: {path: ['integrations', 'email', 'provider']},
|
||||||
FLUXER_EMAIL_FROM_EMAIL: {path: ['integrations', 'email', 'from_email']},
|
FLUXER_EMAIL_FROM_EMAIL: {path: ['integrations', 'email', 'from_email']},
|
||||||
FLUXER_EMAIL_FROM_NAME: {path: ['integrations', 'email', 'from_name']},
|
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_APP_BASE_URL: {path: ['integrations', 'email', 'app_base_url']},
|
||||||
FLUXER_EMAIL_WEBHOOK_SECRET: {path: ['integrations', 'email', 'webhook_secret']},
|
FLUXER_EMAIL_WEBHOOK_SECRET: {path: ['integrations', 'email', 'webhook_secret']},
|
||||||
FLUXER_EMAIL_SMTP_HOST: {path: ['integrations', 'email', 'smtp', 'host']},
|
FLUXER_EMAIL_SMTP_HOST: {path: ['integrations', 'email', 'smtp', 'host']},
|
||||||
|
|||||||
Reference in New Issue
Block a user