From fd864f1379f687e635c6336ca2e3fd0903257b76 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 3 Oct 2026 02:24:38 +0200 Subject: [PATCH] feat: add --outgoing-ignore-tls to keep the relay off STARTTLS (#377) The relay connection only had `secure`, so with `secure: false` nodemailer still upgraded to STARTTLS whenever the server advertised it, and there was no way to talk cleartext to such a server. Pass nodemailer's `ignoreTLS` through as `ignoreTLS` on the relay config, wired to --outgoing-ignore-tls / MAILDEV_OUTGOING_IGNORE_TLS / `outgoingIgnoreTls`. --- .changeset/outgoing-ignore-tls.md | 8 ++ docs/api.md | 1 + packages/cli/README.md | 4 + packages/cli/src/__tests__/config/env.test.ts | 8 ++ .../cli/src/__tests__/config/merge.test.ts | 2 + packages/cli/src/config/env.ts | 2 + packages/cli/src/config/merge.ts | 1 + packages/cli/src/config/types.ts | 7 ++ packages/cli/src/options.ts | 4 + packages/cli/src/server/orchestrator.ts | 3 + .../smtp/src/__tests__/relay-starttls.test.ts | 80 +++++++++++++++++++ packages/smtp/src/relay.ts | 2 + packages/smtp/src/types.ts | 2 + 13 files changed, 124 insertions(+) create mode 100644 .changeset/outgoing-ignore-tls.md create mode 100644 packages/smtp/src/__tests__/relay-starttls.test.ts diff --git a/.changeset/outgoing-ignore-tls.md b/.changeset/outgoing-ignore-tls.md new file mode 100644 index 0000000..e85f1d3 --- /dev/null +++ b/.changeset/outgoing-ignore-tls.md @@ -0,0 +1,8 @@ +--- +'@maildev/smtp': patch +'maildev': patch +--- + +Add `--outgoing-ignore-tls` / `MAILDEV_OUTGOING_IGNORE_TLS` (`outgoingIgnoreTls`, +`ignoreTLS` on the relay config) to keep the outgoing relay connection on +cleartext even when the server advertises STARTTLS (#377). diff --git a/docs/api.md b/docs/api.md index 99c3276..77e406c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -68,6 +68,7 @@ const maildev = new MailDev({ outgoingUser: 'user', outgoingPass: 'pass', outgoingSecure: true, + outgoingIgnoreTls: false, // Never upgrade the relay connection with STARTTLS autoRelay: true, // Auto-forward emails // Logging diff --git a/packages/cli/README.md b/packages/cli/README.md index 3afc508..a6df86a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -59,6 +59,7 @@ Usage: maildev [options] | `--outgoing-user ` | `MAILDEV_OUTGOING_USER` | SMTP user for outgoing mail | | `--outgoing-pass ` | `MAILDEV_OUTGOING_PASS` | SMTP password for outgoing mail | | `--outgoing-secure` | `MAILDEV_OUTGOING_SECURE` | Use SMTP SSL for outgoing mail | +| `--outgoing-ignore-tls` | `MAILDEV_OUTGOING_IGNORE_TLS` | Never use STARTTLS for outgoing mail, even if the server offers it | | `--auto-relay [email]` | `MAILDEV_AUTO_RELAY` | Use auto-relay mode. Optional relay email address | | `--auto-relay-rules ` | `MAILDEV_AUTO_RELAY_RULES` | Filter rules for auto relay mode | | `--web-user ` | `MAILDEV_WEB_USER` | HTTP user for GUI | @@ -176,6 +177,9 @@ maildev --outgoing-host smtp.gmail.com \ --outgoing-pass '' ``` +MailDev upgrades a non-secure relay connection with STARTTLS whenever the server offers it. Add +`--outgoing-ignore-tls` to keep it on cleartext, e.g. for a relay on port 587 that does not support TLS. + ### Auto Relay Mode Enabling the auto relay mode will automatically send each email to its recipient diff --git a/packages/cli/src/__tests__/config/env.test.ts b/packages/cli/src/__tests__/config/env.test.ts index 95b048e..9fc017f 100644 --- a/packages/cli/src/__tests__/config/env.test.ts +++ b/packages/cli/src/__tests__/config/env.test.ts @@ -87,6 +87,14 @@ describe('loadEnvConfig', () => { expect(config.outgoingPass).toBe('relay_pass') }) + it('should parse MAILDEV_OUTGOING_IGNORE_TLS as boolean', () => { + process.env.MAILDEV_OUTGOING_IGNORE_TLS = 'true' + expect(loadEnvConfig().outgoingIgnoreTls).toBe(true) + + process.env.MAILDEV_OUTGOING_IGNORE_TLS = 'false' + expect(loadEnvConfig().outgoingIgnoreTls).toBe(false) + }) + it('should load MAILDEV_MAIL_DIRECTORY', () => { process.env.MAILDEV_MAIL_DIRECTORY = '/var/mail' diff --git a/packages/cli/src/__tests__/config/merge.test.ts b/packages/cli/src/__tests__/config/merge.test.ts index 7849125..e3ed45e 100644 --- a/packages/cli/src/__tests__/config/merge.test.ts +++ b/packages/cli/src/__tests__/config/merge.test.ts @@ -160,6 +160,7 @@ describe('resolveConfig', () => { outgoingUser: 'relay_user', outgoingPass: 'relay_pass', outgoingSecure: true, + outgoingIgnoreTls: true, }) expect(config.outgoingHost).toBe('smtp.example.com') @@ -167,6 +168,7 @@ describe('resolveConfig', () => { expect(config.outgoingUser).toBe('relay_user') expect(config.outgoingPass).toBe('relay_pass') expect(config.outgoingSecure).toBe(true) + expect(config.outgoingIgnoreTls).toBe(true) }) it('should handle mail directory option', async () => { diff --git a/packages/cli/src/config/env.ts b/packages/cli/src/config/env.ts index 404d0a5..f8c30c8 100644 --- a/packages/cli/src/config/env.ts +++ b/packages/cli/src/config/env.ts @@ -60,6 +60,7 @@ const NUMBER_VARS = new Set([ const BOOLEAN_VARS = new Set([ 'MAILDEV_INCOMING_SECURE', 'MAILDEV_OUTGOING_SECURE', + 'MAILDEV_OUTGOING_IGNORE_TLS', 'MAILDEV_HTTPS', 'MAILDEV_DISABLE_WEB', 'MAILDEV_MCP', @@ -149,6 +150,7 @@ function envVarToConfigKey(envVar: string): keyof PartialMailDevConfig | null { verbose: 'verbose', silent: 'silent', mcp: 'mcp', + outgoingIgnoreTls: 'outgoingIgnoreTls', } return mapping[camelCase] || null diff --git a/packages/cli/src/config/merge.ts b/packages/cli/src/config/merge.ts index c0a77c1..aa3bbd1 100644 --- a/packages/cli/src/config/merge.ts +++ b/packages/cli/src/config/merge.ts @@ -59,6 +59,7 @@ function cliOptionsToConfig(options: CLIOptions): PartialMailDevConfig { if (options.disableWeb !== undefined) config.disableWeb = options.disableWeb if (options.https !== undefined) config.https = options.https if (options.outgoingSecure !== undefined) config.outgoingSecure = options.outgoingSecure + if (options.outgoingIgnoreTls !== undefined) config.outgoingIgnoreTls = options.outgoingIgnoreTls if (options.verbose !== undefined) config.verbose = options.verbose if (options.silent !== undefined) config.silent = options.silent if (options.logMailContents !== undefined) diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 4db7d2b..0127ddd 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -225,6 +225,12 @@ export interface MailDevConfig { */ outgoingSecure?: boolean + /** + * Never upgrade the outgoing relay connection with STARTTLS + * CLI: --outgoing-ignore-tls + */ + outgoingIgnoreTls?: boolean + /** * Enable auto-relay mode * CLI: --auto-relay [email] @@ -330,6 +336,7 @@ export interface CLIOptions { outgoingUser?: string outgoingPass?: string outgoingSecure?: boolean + outgoingIgnoreTls?: boolean autoRelay?: boolean | string autoRelayRules?: string mailDirectory?: string diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 63f1f9f..98fb179 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -113,6 +113,10 @@ export function configureOptions(program: Command): void { '--outgoing-secure', 'Use TLS/SSL for outgoing relay' ) + .option( + '--outgoing-ignore-tls', + 'Never use STARTTLS for outgoing relay, even if the server offers it' + ) .option( '--auto-relay [email]', 'Enable auto-relay mode (optionally specify recipient email)' diff --git a/packages/cli/src/server/orchestrator.ts b/packages/cli/src/server/orchestrator.ts index ef776ad..0242e1b 100644 --- a/packages/cli/src/server/orchestrator.ts +++ b/packages/cli/src/server/orchestrator.ts @@ -306,6 +306,9 @@ export class Orchestrator { if (this.config.outgoingSecure !== undefined) { config.secure = this.config.outgoingSecure } + if (this.config.outgoingIgnoreTls !== undefined) { + config.ignoreTLS = this.config.outgoingIgnoreTls + } if (this.config.outgoingUser && this.config.outgoingPass) { config.auth = { user: this.config.outgoingUser, diff --git a/packages/smtp/src/__tests__/relay-starttls.test.ts b/packages/smtp/src/__tests__/relay-starttls.test.ts new file mode 100644 index 0000000..4aa1ee4 --- /dev/null +++ b/packages/smtp/src/__tests__/relay-starttls.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { AddressInfo } from 'node:net' +import nodemailer from 'nodemailer' +import { SMTPServer as SMTPServerLib } from 'smtp-server' +import { MemoryStorage } from '@maildev/core' +import { SMTPServer } from '../server.js' + +describe('relay STARTTLS', () => { + let mailDir: string + let storage: MemoryStorage + let source: SMTPServer + let upstream: SMTPServerLib + let upstreamPort: number + // `session.secure` of every message the upstream server received + let deliveries: boolean[] + + beforeEach(async () => { + mailDir = await mkdtemp(join(tmpdir(), 'maildev-relay-tls-')) + storage = new MemoryStorage() + await storage.initialize() + deliveries = [] + + // A plain-port relay that advertises STARTTLS, like a submission server on 587. + upstream = new SMTPServerLib({ + authOptional: true, + logger: false, + onData(stream, session, callback) { + deliveries.push(session.secure) + stream.on('end', () => callback()) + stream.resume() + }, + }) + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', () => resolve())) + upstreamPort = (upstream.server.address() as AddressInfo).port + + source = new SMTPServer({ storage, mailDir, port: 0, host: '127.0.0.1' }) + await source.start() + }) + + afterEach(async () => { + await source.stop() + await new Promise((resolve) => upstream.close(() => resolve())) + await rm(mailDir, { recursive: true, force: true }) + }) + + const send = () => { + const transport = nodemailer.createTransport({ + host: '127.0.0.1', + port: source.getPort(), + secure: false, + }) + return transport.sendMail({ + from: 'a@example.com', + to: 'b@example.com', + subject: 'relay me', + text: 'hello', + }) + } + + it('upgrades the relay connection with STARTTLS when the server offers it', async () => { + source.setupRelay({ host: '127.0.0.1', port: upstreamPort, secure: false }) + source.setAutoRelay({ enabled: true }) + + await send() + + expect(deliveries).toEqual([true]) + }) + + it('stays on cleartext with ignoreTLS even when the server offers STARTTLS', async () => { + source.setupRelay({ host: '127.0.0.1', port: upstreamPort, secure: false, ignoreTLS: true }) + source.setAutoRelay({ enabled: true }) + + await send() + + expect(deliveries).toEqual([false]) + }) +}) diff --git a/packages/smtp/src/relay.ts b/packages/smtp/src/relay.ts index 7fe5850..2896742 100644 --- a/packages/smtp/src/relay.ts +++ b/packages/smtp/src/relay.ts @@ -36,6 +36,7 @@ export class RelayClient { host: config.host || 'localhost', port: config.port || (config.secure ? 465 : 25), secure: config.secure || false, + ignoreTLS: config.ignoreTLS || false, auth: config.auth, } this.logger = createLogger(logger) @@ -152,6 +153,7 @@ export class RelayClient { port: this.config.port, host: this.config.host, secure: this.config.secure, + ignoreTLS: this.config.ignoreTLS, tls: { rejectUnauthorized: false }, debug: false, }) diff --git a/packages/smtp/src/types.ts b/packages/smtp/src/types.ts index 1b81d6b..b8d5dea 100644 --- a/packages/smtp/src/types.ts +++ b/packages/smtp/src/types.ts @@ -86,6 +86,8 @@ export interface RelayConfig { port: number /** Enable TLS/SSL */ secure?: boolean + /** Never upgrade a non-secure connection with STARTTLS, even if the server offers it */ + ignoreTLS?: boolean /** Authentication credentials */ auth?: SMTPAuth | undefined }