From 8cf1150b1586c99e734fedad4677f089d0b10598 Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Fri, 6 Mar 2026 15:05:29 +0800 Subject: [PATCH] feat: add STARTTLS support for SMTP proxy server (#876) * feat: add STARTTLS support for SMTP proxy server Add smtp_tls_cert and smtp_tls_key environment variables to enable STARTTLS on the SMTP proxy server, matching existing IMAP TLS support. Closes #249 Co-Authored-By: Claude Opus 4.6 * test: add E2E tests for SMTP/IMAP STARTTLS - Add smtp-proxy-tls service with self-signed certs in docker-compose - Add smtp-tls.spec.ts: SMTP STARTTLS send plain/HTML/auth tests - Add imap-tls.spec.ts: IMAP STARTTLS login/list/select/fetch tests - Register smtp-proxy project in playwright.config.ts - Wait for TLS proxy readiness in docker-entrypoint.sh Co-Authored-By: Claude Opus 4.6 * fix: enforce auth over TLS when STARTTLS is configured - Set auth_require_tls conditionally based on tls_context presence - Disable insecure SSLv2/SSLv3 protocols in TLS context Co-Authored-By: Claude Opus 4.6 * fix: replace cert-gen service with inline cert generation The cert-gen one-shot container was exiting immediately after generating certificates, triggering --abort-on-container-exit and stopping all services before tests could run. Replace with an entrypoint script in smtp-proxy-tls that generates the self-signed cert before starting the proxy server. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + e2e/Dockerfile.e2e | 2 +- e2e/docker-compose.yml | 28 +++++ e2e/playwright.config.ts | 7 ++ e2e/scripts/docker-entrypoint.sh | 12 ++ e2e/scripts/smtp-tls-entrypoint.sh | 15 +++ e2e/tests/smtp-proxy/imap-tls.spec.ts | 81 +++++++++++++ e2e/tests/smtp-proxy/smtp-tls.spec.ts | 114 ++++++++++++++++++ smtp_proxy_server/.env.example | 2 + smtp_proxy_server/config.py | 2 + smtp_proxy_server/smtp_server.py | 29 ++++- .../en/guide/feature/config-smtp-proxy.md | 12 +- .../zh/guide/feature/config-smtp-proxy.md | 12 +- 14 files changed, 307 insertions(+), 11 deletions(-) create mode 100755 e2e/scripts/smtp-tls-entrypoint.sh create mode 100644 e2e/tests/smtp-proxy/imap-tls.spec.ts create mode 100644 e2e/tests/smtp-proxy/smtp-tls.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2025fd7c..ebe2dd81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - feat: |用户注册| 新增用户注册邮箱正则校验功能,管理员可配置邮箱格式验证规则 - feat: |前端| 新增可配置的 Status 菜单按钮,通过 `STATUS_URL` 环境变量配置状态监控页面链接 +- feat: |SMTP| SMTP 代理服务支持 STARTTLS,通过 `smtp_tls_cert` 和 `smtp_tls_key` 环境变量配置 ### Bug Fixes diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 06632dcf..3e2265e2 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -12,6 +12,7 @@ - feat: |User Registration| Add email regex validation for user registration, admins can configure email format validation rules - feat: |Frontend| Add configurable Status menu button via `STATUS_URL` environment variable for status monitoring page link +- feat: |SMTP| Add STARTTLS support for SMTP proxy server via `smtp_tls_cert` and `smtp_tls_key` environment variables ### Bug Fixes diff --git a/e2e/Dockerfile.e2e b/e2e/Dockerfile.e2e index 98edc48e..e559d1a3 100644 --- a/e2e/Dockerfile.e2e +++ b/e2e/Dockerfile.e2e @@ -1,7 +1,7 @@ # Keep this version in sync with @playwright/test in package.json FROM mcr.microsoft.com/playwright:v1.58.2-noble -RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/* WORKDIR /app/e2e diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index e60ccd08..706b14d7 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -45,6 +45,28 @@ services: worker: condition: service_healthy + smtp-proxy-tls: + build: + context: ../smtp_proxy_server + dockerfile: dockerfile + ports: + - "11026:8026" + - "11144:11144" + environment: + PROXY_URL: http://worker:8787 + PORT: "8026" + IMAP_PORT: "11144" + smtp_tls_cert: /certs/cert.pem + smtp_tls_key: /certs/key.pem + imap_tls_cert: /certs/cert.pem + imap_tls_key: /certs/key.pem + entrypoint: ["/bin/bash", "/e2e-scripts/smtp-tls-entrypoint.sh"] + volumes: + - ./scripts:/e2e-scripts:ro + depends_on: + worker: + condition: service_healthy + e2e-runner: build: context: .. @@ -54,7 +76,11 @@ services: FRONTEND_URL: http://frontend:5173 MAILPIT_API: http://mailpit:8025/api SMTP_PROXY_HOST: smtp-proxy + SMTP_PROXY_SMTP_PORT: "8025" SMTP_PROXY_IMAP_PORT: "11143" + SMTP_PROXY_TLS_HOST: smtp-proxy-tls + SMTP_PROXY_TLS_SMTP_PORT: "8026" + SMTP_PROXY_TLS_IMAP_PORT: "11144" CI: "true" depends_on: worker: @@ -63,6 +89,8 @@ services: condition: service_started smtp-proxy: condition: service_started + smtp-proxy-tls: + condition: service_started volumes: - ./test-results:/app/e2e/test-results - ./playwright-report:/app/e2e/playwright-report diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 245a062f..7e597ea7 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -16,6 +16,13 @@ export default defineConfig({ baseURL: WORKER_BASE, }, }, + { + name: 'smtp-proxy', + testDir: './tests/smtp-proxy', + use: { + baseURL: WORKER_BASE, + }, + }, { name: 'browser', testDir: './tests/browser', diff --git a/e2e/scripts/docker-entrypoint.sh b/e2e/scripts/docker-entrypoint.sh index 9b9bb2c1..72dcf3ad 100755 --- a/e2e/scripts/docker-entrypoint.sh +++ b/e2e/scripts/docker-entrypoint.sh @@ -27,6 +27,18 @@ for i in $(seq 1 60); do sleep 1 done +echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..." +for i in $(seq 1 30); do + if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then + echo " smtp-proxy-tls SMTP ready after ${i}s" + break + fi + if [ "$i" -eq 30 ]; then + echo "WARNING: smtp-proxy-tls SMTP not ready after 30s, continuing anyway" + fi + sleep 1 +done + echo "==> Initializing database" curl -sf -X POST "$WORKER_URL/admin/db_initialize" > /dev/null curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null diff --git a/e2e/scripts/smtp-tls-entrypoint.sh b/e2e/scripts/smtp-tls-entrypoint.sh new file mode 100755 index 00000000..c972d876 --- /dev/null +++ b/e2e/scripts/smtp-tls-entrypoint.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR="/certs" +mkdir -p "$CERT_DIR" + +if [ ! -f "$CERT_DIR/cert.pem" ] || [ ! -f "$CERT_DIR/key.pem" ]; then + echo "==> Generating self-signed TLS certificate" + openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \ + -days 1 -subj "/CN=smtp-proxy-tls" + echo " Certificate generated" +fi + +exec python3 main.py diff --git a/e2e/tests/smtp-proxy/imap-tls.spec.ts b/e2e/tests/smtp-proxy/imap-tls.spec.ts new file mode 100644 index 00000000..613e970b --- /dev/null +++ b/e2e/tests/smtp-proxy/imap-tls.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '@playwright/test'; +import { ImapFlow } from 'imapflow'; +import { createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers'; + +const IMAP_TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls'; +const IMAP_TLS_PORT = parseInt(process.env.SMTP_PROXY_TLS_IMAP_PORT || '11144', 10); + +function createClient(user: string, pass: string) { + return new ImapFlow({ + host: IMAP_TLS_HOST, + port: IMAP_TLS_PORT, + secure: false, + auth: { user, pass }, + logger: false, + tls: { rejectUnauthorized: false }, + }); +} + +test.describe('IMAP Proxy — STARTTLS', () => { + let jwt: string; + let address: string; + + test.beforeAll(async ({ request }) => { + ({ jwt, address } = await createTestAddress(request, 'imap-tls')); + await seedTestMail(request, address, { subject: 'IMAP TLS Test 1', text: 'First TLS test email' }); + await seedTestMail(request, address, { subject: 'IMAP TLS Test 2', text: 'Second TLS test email' }); + }); + + test.afterAll(async ({ request }) => { + await deleteAddress(request, jwt); + }); + + test('login with JWT over STARTTLS', async () => { + const client = createClient(address, jwt); + await client.connect(); + expect(client.usable).toBe(true); + await client.logout(); + }); + + test('login with wrong password fails over STARTTLS', async () => { + const client = createClient(address, 'wrong-password'); + await expect(client.connect()).rejects.toThrow(); + }); + + test('LIST returns INBOX over STARTTLS', async () => { + const client = createClient(address, jwt); + await client.connect(); + const mailboxes = await client.list(); + const names = mailboxes.map(m => m.path); + expect(names).toContain('INBOX'); + await client.logout(); + }); + + test('SELECT INBOX returns messages over STARTTLS', async () => { + const client = createClient(address, jwt); + await client.connect(); + const lock = await client.getMailboxLock('INBOX'); + try { + expect(client.mailbox).toBeTruthy(); + expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2); + } finally { + lock.release(); + } + await client.logout(); + }); + + test('FETCH source over STARTTLS contains valid MIME', async () => { + const client = createClient(address, jwt); + await client.connect(); + const lock = await client.getMailboxLock('INBOX'); + try { + const msg = await client.fetchOne('1', { source: true }); + const source = msg.source.toString('utf-8'); + expect(source).toContain('Content-Type:'); + expect(source).toContain('Subject:'); + } finally { + lock.release(); + } + await client.logout(); + }); +}); diff --git a/e2e/tests/smtp-proxy/smtp-tls.spec.ts b/e2e/tests/smtp-proxy/smtp-tls.spec.ts new file mode 100644 index 00000000..61b1f468 --- /dev/null +++ b/e2e/tests/smtp-proxy/smtp-tls.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from '@playwright/test'; +import nodemailer from 'nodemailer'; +import { + createTestAddress, + deleteAddress, + deleteAllMailpitMessages, + requestSendAccess, + onMailpitMessage, +} from '../../fixtures/test-helpers'; + +const TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls'; +const TLS_SMTP_PORT = parseInt(process.env.SMTP_PROXY_TLS_SMTP_PORT || '8026', 10); + +function createTlsTransport(user: string, pass: string) { + return nodemailer.createTransport({ + host: TLS_HOST, + port: TLS_SMTP_PORT, + secure: false, + auth: { user, pass }, + tls: { rejectUnauthorized: false }, + requireTLS: true, + }); +} + +function createNoTlsTransport(user: string, pass: string) { + return nodemailer.createTransport({ + host: TLS_HOST, + port: TLS_SMTP_PORT, + secure: false, + auth: { user, pass }, + tls: { rejectUnauthorized: false }, + }); +} + +test.describe('SMTP Proxy — STARTTLS', () => { + let jwt: string; + let address: string; + + test.beforeAll(async ({ request }) => { + await deleteAllMailpitMessages(request); + ({ jwt, address } = await createTestAddress(request, 'smtp-tls')); + await requestSendAccess(request, jwt); + }); + + test.afterAll(async ({ request }) => { + await deleteAddress(request, jwt); + }); + + test('send plain text email via STARTTLS', async () => { + const subject = `SMTP TLS Plain ${Date.now()}`; + const listener = onMailpitMessage((m) => m.Subject === subject); + await listener.ready; + + const transport = createTlsTransport(address, jwt); + const info = await transport.sendMail({ + from: address, + to: 'recipient@test.example.com', + subject, + text: 'Hello from SMTP STARTTLS E2E test', + }); + expect(info.accepted).toContain('recipient@test.example.com'); + + const delivered = await listener.message; + expect(delivered.Subject).toBe(subject); + }); + + test('send HTML email via STARTTLS', async () => { + const subject = `SMTP TLS HTML ${Date.now()}`; + const listener = onMailpitMessage((m) => m.Subject === subject); + await listener.ready; + + const transport = createTlsTransport(address, jwt); + const info = await transport.sendMail({ + from: address, + to: 'recipient@test.example.com', + subject, + html: '

Hello

STARTTLS HTML E2E test

', + }); + expect(info.accepted).toContain('recipient@test.example.com'); + + const delivered = await listener.message; + expect(delivered.Subject).toBe(subject); + }); + + test('connection without STARTTLS still works', async () => { + const subject = `SMTP TLS NoForce ${Date.now()}`; + const listener = onMailpitMessage((m) => m.Subject === subject); + await listener.ready; + + const transport = createNoTlsTransport(address, jwt); + const info = await transport.sendMail({ + from: address, + to: 'recipient@test.example.com', + subject, + text: 'Hello without forced STARTTLS', + }); + expect(info.accepted).toContain('recipient@test.example.com'); + + const delivered = await listener.message; + expect(delivered.Subject).toBe(subject); + }); + + test('auth with wrong password fails over STARTTLS', async () => { + const transport = createTlsTransport(address, 'wrong-password'); + await expect( + transport.sendMail({ + from: address, + to: 'recipient@test.example.com', + subject: 'Should fail', + text: 'This should not be sent', + }) + ).rejects.toThrow(); + }); +}); diff --git a/smtp_proxy_server/.env.example b/smtp_proxy_server/.env.example index 902e3bc6..fb4499e3 100644 --- a/smtp_proxy_server/.env.example +++ b/smtp_proxy_server/.env.example @@ -1,6 +1,8 @@ proxy_url=https://temp-email-api.xxx.xxx port=8025 imap_port=11143 +# smtp_tls_cert=/path/to/cert.pem +# smtp_tls_key=/path/to/key.pem # imap_tls_cert=/path/to/cert.pem # imap_tls_key=/path/to/key.pem # imap_cache_size=500 diff --git a/smtp_proxy_server/config.py b/smtp_proxy_server/config.py index 7dda9eb2..a8da76ea 100644 --- a/smtp_proxy_server/config.py +++ b/smtp_proxy_server/config.py @@ -15,6 +15,8 @@ class Settings(BaseSettings): port: int = 8025 imap_port: int = 11143 basic_password: str = "" + smtp_tls_cert: str = "" + smtp_tls_key: str = "" imap_tls_cert: str = "" imap_tls_key: str = "" imap_cache_size: int = 500 diff --git a/smtp_proxy_server/smtp_server.py b/smtp_proxy_server/smtp_server.py index 97330ea8..1394f322 100644 --- a/smtp_proxy_server/smtp_server.py +++ b/smtp_proxy_server/smtp_server.py @@ -1,6 +1,8 @@ import asyncio import logging import email +import ssl + import httpx from aiosmtpd.controller import Controller @@ -132,17 +134,35 @@ class CustomSMTPHandler: def start_smtp_server(): handler = CustomSMTPHandler() + + tls_context = None + has_cert = bool(settings.smtp_tls_cert) + has_key = bool(settings.smtp_tls_key) + if has_cert != has_key: + raise ValueError( + "Both smtp_tls_cert and smtp_tls_key must be set together" + ) + if has_cert and has_key: + _logger.info("TLS enabled for SMTP (STARTTLS)") + tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + tls_context.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 + tls_context.load_cert_chain(settings.smtp_tls_cert, settings.smtp_tls_key) + server = Controller( handler, hostname="", port=settings.port, - auth_require_tls=False, + auth_require_tls=bool(tls_context), decode_data=True, authenticator=handler.authenticator, - auth_exclude_mechanism=["DONT"] + auth_exclude_mechanism=["DONT"], + tls_context=tls_context, ) - _logger.info("Starting SMTP server on port %s", settings.port) + _logger.info( + "Starting SMTP server on port %s tls=%s", + settings.port, bool(tls_context), + ) server.start() loop = asyncio.new_event_loop() @@ -156,7 +176,8 @@ def start_smtp_server(): if __name__ == "__main__": _logger.info( - "Starting SMTP server proxy_url=%s port=%s", + "Starting SMTP server proxy_url=%s port=%s tls=%s", settings.proxy_url, settings.port, + bool(settings.smtp_tls_cert and settings.smtp_tls_key), ) start_smtp_server() diff --git a/vitepress-docs/docs/en/guide/feature/config-smtp-proxy.md b/vitepress-docs/docs/en/guide/feature/config-smtp-proxy.md index 0c045b80..08f4cf03 100644 --- a/vitepress-docs/docs/en/guide/feature/config-smtp-proxy.md +++ b/vitepress-docs/docs/en/guide/feature/config-smtp-proxy.md @@ -58,17 +58,21 @@ services: | `proxy_url` | `http://localhost:8787` | Worker backend URL | | `port` | `8025` | SMTP port | | `imap_port` | `11143` | IMAP port | -| `imap_tls_cert` | empty | TLS certificate file path (PEM), enables STARTTLS when configured | -| `imap_tls_key` | empty | TLS private key file path (PEM) | +| `smtp_tls_cert` | empty | SMTP TLS certificate file path (PEM), enables STARTTLS when configured | +| `smtp_tls_key` | empty | SMTP TLS private key file path (PEM) | +| `imap_tls_cert` | empty | IMAP TLS certificate file path (PEM), enables STARTTLS when configured | +| `imap_tls_key` | empty | IMAP TLS private key file path (PEM) | | `imap_cache_size` | `500` | Max cached messages per mailbox | | `imap_http_timeout` | `30.0` | Backend HTTP request timeout (seconds) | ## Enabling STARTTLS -Configure `imap_tls_cert` and `imap_tls_key` environment variables to enable STARTTLS support for the IMAP server. +Configure the TLS certificate environment variables for SMTP and/or IMAP to enable STARTTLS support. SMTP and IMAP can share the same certificate. ```bash # .env example +smtp_tls_cert=/path/to/cert.pem +smtp_tls_key=/path/to/key.pem imap_tls_cert=/path/to/cert.pem imap_tls_key=/path/to/key.pem ``` @@ -77,6 +81,8 @@ In Docker Compose: ```yaml environment: + - smtp_tls_cert=/certs/cert.pem + - smtp_tls_key=/certs/key.pem - imap_tls_cert=/certs/cert.pem - imap_tls_key=/certs/key.pem volumes: diff --git a/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md b/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md index 0176c4fd..95484105 100644 --- a/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md +++ b/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md @@ -58,17 +58,21 @@ services: | `proxy_url` | `http://localhost:8787` | Worker 后端 URL | | `port` | `8025` | SMTP 端口 | | `imap_port` | `11143` | IMAP 端口 | -| `imap_tls_cert` | 空 | TLS 证书文件路径(PEM),配置后启用 STARTTLS | -| `imap_tls_key` | 空 | TLS 私钥文件路径(PEM) | +| `smtp_tls_cert` | 空 | SMTP TLS 证书文件路径(PEM),配置后启用 STARTTLS | +| `smtp_tls_key` | 空 | SMTP TLS 私钥文件路径(PEM) | +| `imap_tls_cert` | 空 | IMAP TLS 证书文件路径(PEM),配置后启用 STARTTLS | +| `imap_tls_key` | 空 | IMAP TLS 私钥文件路径(PEM) | | `imap_cache_size` | `500` | 每个邮箱的消息缓存上限 | | `imap_http_timeout` | `30.0` | 后端 HTTP 请求超时时间(秒) | ## 启用 STARTTLS -配置 `imap_tls_cert` 和 `imap_tls_key` 环境变量后,IMAP 服务会自动支持 STARTTLS。 +分别配置 SMTP 和 IMAP 的 TLS 证书环境变量后,对应服务会自动支持 STARTTLS。SMTP 和 IMAP 可以使用同一套证书。 ```bash # .env 示例 +smtp_tls_cert=/path/to/cert.pem +smtp_tls_key=/path/to/key.pem imap_tls_cert=/path/to/cert.pem imap_tls_key=/path/to/key.pem ``` @@ -77,6 +81,8 @@ Docker Compose 中配置: ```yaml environment: + - smtp_tls_cert=/certs/cert.pem + - smtp_tls_key=/certs/key.pem - imap_tls_cert=/certs/cert.pem - imap_tls_key=/certs/key.pem volumes: