fix(email): test saved SMTP config and add TLS modes (#3388)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 13:30:00 +02:00
1 parent 45b78a13c2
commit cca8227ca6
66 files changed
+2464 -319

No files matched your search

+3 -1
View File
@@ -5,6 +5,7 @@ import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRate
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
import {smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
@@ -280,7 +281,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
port: master.integrations.email.smtp.port,
username: master.integrations.email.smtp.username,
password: master.integrations.email.smtp.password,
secure: master.integrations.email.smtp.secure ?? true,
tlsMode:
master.integrations.email.smtp.tls_mode ?? smtpTlsModeFromSecure(master.integrations.email.smtp.secure),
}
: undefined,
},
@@ -25,6 +25,7 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
import {
@@ -71,6 +72,16 @@ function readOptionalField<T extends object, K extends keyof T>(value: T, key: K
return Object.hasOwn(value, key) ? value[key] : undefined;
}
function readSmtpTlsModeUpdate(smtp: {
tls_mode?: SmtpTlsMode | null;
secure?: boolean | null;
}): SmtpTlsMode | null | undefined {
const tlsMode = readOptionalField(smtp, 'tls_mode');
if (tlsMode !== undefined) return tlsMode;
const secure = readOptionalField(smtp, 'secure');
return secure === undefined ? undefined : smtpTlsModeFromSecure(secure);
}
function mergeOptionalField<T>(currentValue: T, nextValue: T | undefined): T {
return nextValue === undefined ? currentValue : nextValue;
}
@@ -554,7 +565,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
port: readOptionalField(data.integrations.email.smtp, 'port'),
username: readOptionalField(data.integrations.email.smtp, 'username'),
password: readOptionalField(data.integrations.email.smtp, 'password'),
secure: readOptionalField(data.integrations.email.smtp, 'secure'),
tls_mode: readSmtpTlsModeUpdate(data.integrations.email.smtp),
})
: undefined,
}
@@ -689,22 +700,19 @@ export function InstanceConfigAdminController(app: HonoApp) {
operationId: 'create_admin_instance_smtp_test',
summary: 'Run an SMTP configuration test',
description:
'Validates that an SMTP configuration can authenticate and accept a connection. Requires INSTANCE_CONFIG_UPDATE permission.',
'Validates that an SMTP configuration can authenticate and accept a connection. Fields left out of the body come from the saved configuration, and the saved password is reused only for the saved host and username. An empty body tests what outgoing email uses and fails when email is off or incomplete. Requires INSTANCE_CONFIG_UPDATE permission.',
responseSchema: InstanceEmailSmtpTestResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const data = ctx.req.valid('json');
const {smtp, problem} = await instanceConfigRepository.getSmtpTestConfig(ctx.req.valid('json'));
let result: InstanceEmailSmtpTestResponse;
try {
if (problem) throw new Error(problem);
const provider = new SmtpEmailProvider({
host: data.host,
port: data.port,
username: data.username,
password: data.password,
secure: data.secure,
...smtp,
connectionTimeoutMs: 10000,
greetingTimeoutMs: 10000,
socketTimeoutMs: 10000,
@@ -718,7 +726,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
targetType: 'instance_config',
targetId: 0n,
action: 'test_smtp_connection',
metadata: {port: data.port, secure: data.secure, ok: result.ok},
metadata: {port: smtp.port, tls_mode: resolveSmtpTlsMode(smtp.tlsMode, smtp.port), ok: result.ok},
});
return ctx.json(result);
},
@@ -0,0 +1,200 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
import {getConfig} from '@app/api/Config';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {
InstanceConfigResponse,
InstanceEmailSmtpTestResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import nodemailer, {type Transporter} from 'nodemailer';
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('instance SMTP settings', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
const verify = vi.fn();
const createTransport = vi.spyOn(nodemailer, 'createTransport');
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
createTransport.mockReset();
createTransport.mockReturnValue({verify} as unknown as Transporter);
verify.mockReset();
verify.mockResolvedValue(true);
admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
});
afterAll(async () => {
await harness.shutdown();
createTransport.mockRestore();
});
async function patchSmtp(smtp: Record<string, unknown>) {
const config = await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch('/admin/instance/config')
.body({integrations: {email: {smtp}}})
.execute();
return config.integrations.email.smtp;
}
async function runSmtpTest(body: Record<string, unknown>) {
return await createBuilder<InstanceEmailSmtpTestResponse>(harness, admin.token)
.post('/admin/instance/config/smtp-tests')
.body(body)
.execute();
}
it('tests the saved settings when the request names none', async () => {
await patchSmtp({
host: 'smtp.example.com',
port: 587,
username: 'mailer',
password: 'saved-password',
tls_mode: 'implicit',
});
await expect(runSmtpTest({})).resolves.toEqual({ok: true, error: null});
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({
host: 'smtp.example.com',
port: 587,
secure: true,
requireTLS: false,
ignoreTLS: false,
auth: {user: 'mailer', pass: 'saved-password'},
}),
);
});
it('reports the failure a send with the saved settings would hit', async () => {
await patchSmtp({host: 'smtp.example.com', port: 587, username: 'mailer', password: 'saved-password'});
verify.mockRejectedValue(new Error('wrong version number'));
await expect(runSmtpTest({})).resolves.toEqual({ok: false, error: 'wrong version number'});
});
it('fills a draft for the saved host with the saved password', async () => {
await patchSmtp({
host: 'smtp.example.com',
port: 465,
username: 'mailer',
password: 'saved-password',
tls_mode: 'implicit',
});
await expect(runSmtpTest({host: 'SMTP.example.com', port: 2525, tls_mode: null})).resolves.toEqual({
ok: true,
error: null,
});
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({
host: 'SMTP.example.com',
port: 2525,
secure: false,
ignoreTLS: true,
auth: {user: 'mailer', pass: 'saved-password'},
}),
);
});
it.each([{host: 'draft.example.com'}, {username: 'someone-else'}, {host: 'draft.example.com', password: null}])(
'keeps the saved password away from the draft %j',
async (draft) => {
await patchSmtp({host: 'smtp.example.com', port: 465, username: 'mailer', password: 'saved-password'});
await expect(runSmtpTest(draft)).resolves.toEqual({
ok: false,
error: 'Enter the SMTP password to test a different host or username',
});
expect(createTransport).not.toHaveBeenCalled();
},
);
it('tests a draft for another host with the password it brings', async () => {
await patchSmtp({host: 'smtp.example.com', port: 465, username: 'mailer', password: 'saved-password'});
await runSmtpTest({host: 'draft.example.com', username: 'drafter', password: 'draft-password'});
expect(createTransport).toHaveBeenCalledWith(
expect.objectContaining({host: 'draft.example.com', auth: {user: 'drafter', pass: 'draft-password'}}),
);
});
it.each([
[{enabled: false}, 'Email is turned off'],
[{provider: 'none'}, 'The email provider is not SMTP'],
])('fails the saved test when %j stops outgoing email', async (email, error) => {
await patchSmtp({host: 'smtp.example.com', port: 587, username: 'mailer', password: 'saved-password'});
await createBuilder<InstanceConfigResponse>(harness, admin.token)
.patch('/admin/instance/config')
.body({integrations: {email}})
.execute();
await expect(runSmtpTest({})).resolves.toEqual({ok: false, error});
expect(createTransport).not.toHaveBeenCalled();
});
it.each([
[{fromEmail: ''}, 'No from address is set'],
[{smtp: {host: '', port: 587, username: 'mailer', password: 'pass', tlsMode: null}}, 'No SMTP host is configured'],
[
{smtp: {host: 'relay.internal', port: 25, username: '', password: '', tlsMode: null}},
'The SMTP username and password are both required',
],
])('fails the saved test when the environment leaves %j', async (environment, error) => {
const original = {...getConfig().email};
Object.assign(getConfig().email, environment);
try {
await expect(runSmtpTest({})).resolves.toEqual({ok: false, error});
expect(createTransport).not.toHaveBeenCalled();
} finally {
Object.assign(getConfig().email, original);
}
});
it('picks the TLS mode from the port until one is saved', async () => {
const original = getConfig().email.smtp;
getConfig().email.smtp = original ? {...original, tlsMode: null} : original;
try {
expect(await patchSmtp({host: 'smtp.example.com', port: 587})).toMatchObject({
tls_mode: null,
effective_tls_mode: 'starttls',
});
expect(await patchSmtp({port: 465})).toMatchObject({tls_mode: null, effective_tls_mode: 'implicit'});
expect(await patchSmtp({tls_mode: 'none'})).toMatchObject({tls_mode: 'none', effective_tls_mode: 'none'});
} finally {
getConfig().email.smtp = original;
}
});
it('reads the legacy secure flag and lets the mode replace it', async () => {
expect(await patchSmtp({host: 'smtp.example.com', port: 587, secure: true})).toMatchObject({
tls_mode: 'implicit',
});
expect(await patchSmtp({secure: false})).toMatchObject({tls_mode: 'opportunistic'});
expect(await patchSmtp({tls_mode: 'starttls'})).toMatchObject({tls_mode: 'starttls'});
});
it('clears every saved field so the environment applies again', async () => {
await patchSmtp({
host: 'smtp.example.com',
port: 465,
username: 'mailer',
password: 'saved-password',
tls_mode: 'implicit',
});
const environment = getConfig().email.smtp;
expect(await patchSmtp({host: null, port: null, username: null, password: null, tls_mode: null})).toMatchObject({
host: environment?.host,
port: environment?.port,
username: environment?.username,
tls_mode: null,
effective_tls_mode: 'none',
});
});
});
@@ -102,13 +102,13 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
return {
request: {
path: '/admin/instance/config/smtp-tests',
body: {host: '127.0.0.1', port: 1, username: 'coverage', password: 'coverage-password', secure: false},
body: {host: '127.0.0.1', port: 1, username: 'coverage', password: 'coverage-password', tls_mode: 'none'},
},
expected: {
action: 'test_smtp_connection',
targetType: 'instance_config',
targetId: '0',
metadata: {port: '1', secure: 'false', ok: 'false'},
metadata: {port: '1', tls_mode: 'none', ok: 'false'},
},
};
},
+2 -1
View File
@@ -3,6 +3,7 @@
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {CachePurgeAdapterName, StoreProductSlotName} from '@fluxer/config/src/MasterConfig';
import type {AccountIdentityMode, TagStyle} from '@fluxer/constants/src/AccountIdentityConstants';
import type {SmtpTlsMode} from '@fluxer/constants/src/SmtpConstants';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
export type APIWorkerLaneName = 'realtime' | 'unfurl' | 'lifecycle' | 'batch' | 'crosspost';
@@ -163,7 +164,7 @@ export interface APIConfig {
port: number;
username: string;
password: string;
secure: boolean;
tlsMode: SmtpTlsMode | null;
};
};
blocklistFeeds: {
@@ -16,7 +16,7 @@ export function createEmailProvider(emailConfig: APIConfig['email']): IEmailProv
port: emailConfig.smtp.port,
username: emailConfig.smtp.username,
password: emailConfig.smtp.password,
secure: emailConfig.smtp.secure,
tlsMode: emailConfig.smtp.tlsMode,
})
: null;
default:
@@ -51,11 +51,13 @@ import {
TagStyles,
} from '@fluxer/constants/src/AccountIdentityConstants';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {resolveSmtpTlsMode, type SmtpTlsMode, smtpTlsModeFromSecure} from '@fluxer/constants/src/SmtpConstants';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
type InstanceEmailSmtpTestRequest,
type PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
@@ -229,6 +231,7 @@ interface InstanceEmailSmtpIntegrationConfig {
port: number | null;
username: string | null;
password: string | null;
tls_mode: SmtpTlsMode | null;
secure: boolean | null;
}
@@ -290,7 +293,8 @@ interface InstanceIntegrationsAdminConfig {
port: number | null;
username: string | null;
password_set: boolean;
secure: boolean | null;
tls_mode: SmtpTlsMode | null;
effective_tls_mode: SmtpTlsMode | null;
};
disable_new_ip_authorization: boolean;
effective_disable_new_ip_authorization: boolean;
@@ -345,7 +349,7 @@ interface InstanceIntegrationsConfigPatch {
gif?: Partial<InstanceGifIntegrationConfig>;
youtube?: Partial<InstanceYoutubeIntegrationConfig>;
email?: Partial<Omit<InstanceEmailIntegrationConfig, 'smtp'>> & {
smtp?: Partial<InstanceEmailSmtpIntegrationConfig>;
smtp?: Partial<Omit<InstanceEmailSmtpIntegrationConfig, 'secure'>>;
};
bluesky?: Partial<Omit<InstanceBlueskyIntegrationConfig, 'keys'>> & {
keys?: Array<Partial<InstanceBlueskyKeyIntegrationConfig>>;
@@ -826,6 +830,7 @@ const StoredInstanceIntegrationsSchema = z.object({
port: SmtpIntegrationUpdateSchema.shape.port.default(null),
username: StoredIntegrationStringSchema,
password: StoredIntegrationStringSchema,
tls_mode: SmtpIntegrationUpdateSchema.shape.tls_mode.default(null),
secure: StoredNullableBooleanSchema,
})
.prefault({}),
@@ -980,15 +985,37 @@ function parseStoredInstanceMediaConfig(raw: string | null): InstanceMediaConfig
return decodeInstanceMediaConfig(parseStoredConfigValue(raw, 'media'));
}
type EffectiveSmtpConfig = NonNullable<APIConfig['email']['smtp']>;
interface SmtpTestConfig {
smtp: EffectiveSmtpConfig;
problem: string | null;
}
function storedSmtpTlsMode(smtp: InstanceEmailSmtpIntegrationConfig): SmtpTlsMode | null {
return smtp.tls_mode ?? smtpTlsModeFromSecure(smtp.secure);
}
function resolveEffectiveSmtpConfig(smtp: InstanceEmailSmtpIntegrationConfig): EffectiveSmtpConfig {
return {
host: smtp.host ?? Config.email.smtp?.host ?? '',
port: smtp.port ?? Config.email.smtp?.port ?? 587,
username: smtp.username ?? Config.email.smtp?.username ?? '',
password: smtp.password ?? Config.email.smtp?.password ?? '',
tlsMode: storedSmtpTlsMode(smtp) ?? Config.email.smtp?.tlsMode ?? null,
};
}
function describeIncompleteSmtpConfig(smtp: EffectiveSmtpConfig): string | null {
if (!smtp.host.trim()) return 'No SMTP host is configured';
if (!smtp.port) return 'No SMTP port is configured';
if (!smtp.username.trim() || !smtp.password.trim()) return 'The SMTP username and password are both required';
return null;
}
function hasCompleteSmtpConfig(config: APIConfig['email']): boolean {
if (config.provider !== 'smtp' || !config.smtp) return false;
return Boolean(
config.fromEmail.trim() &&
config.smtp.host.trim() &&
config.smtp.port &&
config.smtp.username.trim() &&
config.smtp.password.trim(),
);
return Boolean(config.fromEmail.trim()) && describeIncompleteSmtpConfig(config.smtp) === null;
}
const StoredRegistrationConfigSchema = InstanceRegistrationSchema.extend({
@@ -1878,6 +1905,7 @@ export class InstanceConfigRepository {
smtp: {
...current.email.smtp,
...(config.email?.smtp ?? {}),
...(config.email?.smtp?.tls_mode === undefined ? {} : {secure: null}),
},
},
bluesky: {
@@ -1943,22 +1971,35 @@ export class InstanceConfigRepository {
return integrations.youtube.api_key ?? normalizeOptionalString(Config.youtube.apiKey);
}
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
async getSmtpTestConfig(draft: InstanceEmailSmtpTestRequest): Promise<SmtpTestConfig> {
const saved = (await this.getInstanceIntegrationsConfig()).email.smtp;
const current = resolveEffectiveSmtpConfig(saved);
if (Object.values(draft).every((value) => value === undefined)) {
return {smtp: current, problem: await this.getEmailSendBlocker()};
}
const retargeted =
(draft.host != null && draft.host.toLowerCase() !== current.host.toLowerCase()) ||
(draft.username != null && draft.username !== current.username);
const smtp: EffectiveSmtpConfig = {
host: draft.host ?? current.host,
port: draft.port ?? current.port,
username: draft.username ?? current.username,
password: draft.password ?? (retargeted ? '' : current.password),
tlsMode: draft.tls_mode === undefined ? current.tlsMode : (draft.tls_mode ?? Config.email.smtp?.tlsMode ?? null),
};
if (retargeted && !smtp.password) {
return {smtp, problem: 'Enter the SMTP password to test a different host or username'};
}
return {smtp, problem: describeIncompleteSmtpConfig(smtp)};
}
private async resolveEmailConfig(): Promise<{email: APIConfig['email']; usernameMode: boolean}> {
const integrations = await this.getInstanceIntegrationsConfig();
const provider = integrations.email.provider ?? Config.email.provider;
const fromEmail = integrations.email.from_email ?? Config.email.fromEmail;
const fromName = integrations.email.from_name ?? Config.email.fromName;
const smtp =
provider === 'smtp'
? {
host: integrations.email.smtp.host ?? Config.email.smtp?.host ?? '',
port: integrations.email.smtp.port ?? Config.email.smtp?.port ?? 587,
username: integrations.email.smtp.username ?? Config.email.smtp?.username ?? '',
password: integrations.email.smtp.password ?? Config.email.smtp?.password ?? '',
secure: integrations.email.smtp.secure ?? Config.email.smtp?.secure ?? true,
}
: undefined;
const next: APIConfig['email'] = {
const smtp = provider === 'smtp' ? resolveEffectiveSmtpConfig(integrations.email.smtp) : undefined;
const email: APIConfig['email'] = {
...Config.email,
enabled: integrations.email.enabled ?? Config.email.enabled,
provider,
@@ -1967,9 +2008,23 @@ export class InstanceConfigRepository {
smtp,
};
const usernameMode = (await this.getAccountIdentityMode()) === AccountIdentityModes.USERNAME;
return {email, usernameMode};
}
private async getEmailSendBlocker(): Promise<string | null> {
const {email, usernameMode} = await this.resolveEmailConfig();
if (usernameMode) return 'Email is off while accounts sign in with usernames';
if (!email.enabled) return 'Email is turned off';
if (email.provider !== 'smtp' || !email.smtp) return 'The email provider is not SMTP';
if (!email.fromEmail.trim()) return 'No from address is set';
return describeIncompleteSmtpConfig(email.smtp);
}
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
const {email, usernameMode} = await this.resolveEmailConfig();
return {
...next,
enabled: !usernameMode && next.enabled && hasCompleteSmtpConfig(next),
...email,
enabled: !usernameMode && email.enabled && hasCompleteSmtpConfig(email),
};
}
@@ -2034,7 +2089,8 @@ export class InstanceConfigRepository {
port: email.smtp?.port ?? null,
username: email.smtp?.username || null,
password_set: secretIsSet(integrations.email.smtp.password) || secretIsSet(Config.email.smtp?.password),
secure: email.smtp?.secure ?? null,
tls_mode: storedSmtpTlsMode(integrations.email.smtp),
effective_tls_mode: email.smtp ? resolveSmtpTlsMode(email.smtp.tlsMode, email.smtp.port) : null,
},
disable_new_ip_authorization: integrations.email.disable_new_ip_authorization ?? false,
effective_disable_new_ip_authorization: integrations.email.disable_new_ip_authorization || !email.enabled,
+1 -1
View File
@@ -81,7 +81,7 @@ function setDefaultTestEnv(): void {
FLUXER_EMAIL_SMTP_PORT: '1025',
FLUXER_EMAIL_SMTP_USERNAME: 'test',
FLUXER_EMAIL_SMTP_PASSWORD: 'test',
FLUXER_EMAIL_SMTP_SECURE: 'false',
FLUXER_EMAIL_SMTP_TLS_MODE: 'none',
FLUXER_LIVEKIT_ENABLED: 'false',
FLUXER_STRIPE_ENABLED: 'true',
FLUXER_SEARCH_ENGINE: 'elasticsearch',