Upstream maildev/maildev state of the touched files before f000b5e316

This commit is contained in:
upstream committed 2026-10-03 02:24:38 +02:00
1 parent 3f93b59a2e
commit a9a83f64f1
10 files changed
+2167 -30

No files matched your search

@@ -101,29 +101,6 @@ describe('loadEnvConfig', () => {
expect(config.basePathname).toBe('/mail/')
})
it('should parse MAILDEV_HTTPS=true as boolean true', () => {
process.env.MAILDEV_HTTPS = 'true'
const config = loadEnvConfig()
expect(config.https).toBe(true)
})
it('should parse MAILDEV_HTTPS=false as boolean false', () => {
process.env.MAILDEV_HTTPS = 'false'
const config = loadEnvConfig()
expect(config.https).toBe(false)
})
it('should load MAILDEV_HTTPS_CERT and MAILDEV_HTTPS_KEY as path strings', () => {
process.env.MAILDEV_HTTPS_CERT = '/ssl/cert.pem'
process.env.MAILDEV_HTTPS_KEY = '/ssl/key.pem'
const config = loadEnvConfig()
expect(config.httpsCert).toBe('/ssl/cert.pem')
expect(config.httpsKey).toBe('/ssl/key.pem')
})
it('should ignore invalid port numbers', () => {
process.env.MAILDEV_SMTP_PORT = 'not-a-number'
@@ -0,0 +1,195 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { resolveConfig } from '../../config/merge.js'
// Mock the loader module to avoid file system access
vi.mock('../../config/loader.js', () => ({
loadConfig: vi.fn().mockResolvedValue({ config: {}, filePath: null }),
}))
describe('resolveConfig', () => {
const originalEnv = process.env
beforeEach(() => {
process.env = { ...originalEnv }
// Clear any MAILDEV_ env vars
Object.keys(process.env).forEach((key) => {
if (key.startsWith('MAILDEV_')) {
delete process.env[key]
}
})
})
afterEach(() => {
process.env = originalEnv
vi.clearAllMocks()
})
it('should return default config when no options provided', async () => {
const { config } = await resolveConfig({})
expect(config.smtp).toBe(1025)
expect(config.web).toBe(1080)
expect(config.ip).toBe('::')
expect(config.webIp).toBe('0.0.0.0')
expect(config.basePathname).toBe('/')
expect(config.verbose).toBe(false)
expect(config.mcp).toBe(false)
})
it('should apply CLI options over defaults', async () => {
const { config } = await resolveConfig({
smtp: '2025',
web: '3080',
verbose: true,
})
expect(config.smtp).toBe(2025)
expect(config.web).toBe(3080)
expect(config.verbose).toBe(true)
})
it('should apply environment variables over defaults', async () => {
process.env.MAILDEV_SMTP_PORT = '4025'
process.env.MAILDEV_WEB_PORT = '5080'
const { config } = await resolveConfig({})
expect(config.smtp).toBe(4025)
expect(config.web).toBe(5080)
})
it('should prioritize CLI options over environment variables', async () => {
process.env.MAILDEV_SMTP_PORT = '4025'
const { config } = await resolveConfig({
smtp: '5025',
})
expect(config.smtp).toBe(5025)
})
it('should parse string port options as numbers', async () => {
const { config } = await resolveConfig({
smtp: '9025',
web: '9080',
outgoingPort: '587',
})
expect(config.smtp).toBe(9025)
expect(typeof config.smtp).toBe('number')
expect(config.web).toBe(9080)
expect(typeof config.web).toBe('number')
expect(config.outgoingPort).toBe(587)
expect(typeof config.outgoingPort).toBe('number')
})
it('should handle string options correctly', async () => {
const { config } = await resolveConfig({
ip: '127.0.0.1',
webIp: '192.168.1.1',
basePathname: '/mail/',
incomingUser: 'smtp_user',
incomingPass: 'smtp_pass',
})
expect(config.ip).toBe('127.0.0.1')
expect(config.webIp).toBe('192.168.1.1')
expect(config.basePathname).toBe('/mail/')
expect(config.incomingUser).toBe('smtp_user')
expect(config.incomingPass).toBe('smtp_pass')
})
it('should handle boolean options correctly', async () => {
const { config } = await resolveConfig({
verbose: true,
silent: false,
mcp: true,
disableWeb: true,
})
expect(config.verbose).toBe(true)
expect(config.silent).toBe(false)
expect(config.mcp).toBe(true)
expect(config.disableWeb).toBe(true)
})
it('should handle auto-relay as boolean', async () => {
const { config } = await resolveConfig({
autoRelay: true,
})
expect(config.autoRelay).toBe(true)
})
it('should handle auto-relay as email string', async () => {
const { config } = await resolveConfig({
autoRelay: 'test@example.com',
})
expect(config.autoRelay).toBe('test@example.com')
})
it('should parse hideExtensions as array', async () => {
const { config } = await resolveConfig({
hideExtensions: 'STARTTLS,PIPELINING',
})
expect(config.hideExtensions).toEqual(['STARTTLS', 'PIPELINING'])
})
it('should handle hideExtensions with spaces', async () => {
const { config } = await resolveConfig({
hideExtensions: ' STARTTLS , PIPELINING , 8BITMIME ',
})
expect(config.hideExtensions).toEqual(['STARTTLS', 'PIPELINING', '8BITMIME'])
})
it('should convert hideExtensions to uppercase', async () => {
const { config } = await resolveConfig({
hideExtensions: 'starttls,pipelining',
})
expect(config.hideExtensions).toEqual(['STARTTLS', 'PIPELINING'])
})
it('should handle outgoing relay options', async () => {
const { config } = await resolveConfig({
outgoingHost: 'smtp.example.com',
outgoingPort: '587',
outgoingUser: 'relay_user',
outgoingPass: 'relay_pass',
outgoingSecure: true,
})
expect(config.outgoingHost).toBe('smtp.example.com')
expect(config.outgoingPort).toBe(587)
expect(config.outgoingUser).toBe('relay_user')
expect(config.outgoingPass).toBe('relay_pass')
expect(config.outgoingSecure).toBe(true)
})
it('should handle mail directory option', async () => {
const { config } = await resolveConfig({
mailDirectory: '/var/mail/test',
})
expect(config.mailDirectory).toBe('/var/mail/test')
})
it('should return null configFilePath when no config file found', async () => {
const { configFilePath } = await resolveConfig({})
expect(configFilePath).toBeNull()
})
it('should not override defined values when CLI options not provided', async () => {
process.env.MAILDEV_SMTP_PORT = '4025'
// Not providing smtp option at all (vs providing undefined)
const { config } = await resolveConfig({})
// Environment variable should apply
expect(config.smtp).toBe(4025)
})
})
-1
View File
@@ -145,7 +145,6 @@ function envVarToConfigKey(envVar: string): keyof PartialMailDevConfig | null {
webPort: 'web',
incomingSecure: 'incomingSecure',
outgoingSecure: 'outgoingSecure',
https: 'https',
disableWeb: 'disableWeb',
verbose: 'verbose',
silent: 'silent',
+127
View File
@@ -0,0 +1,127 @@
/**
* MailDev Configuration Merger
*
* Merges configuration from multiple sources with proper priority:
* CLI args (highest) > Environment vars > Config file > Defaults (lowest)
*/
import type { MailDevConfig, PartialMailDevConfig, CLIOptions } from './types.js'
import { getDefaultConfig } from './defaults.js'
import { loadEnvConfig } from './env.js'
import { loadConfig } from './loader.js'
import type { HideableExtension } from '@maildev/smtp'
/**
* Convert CLI options to partial config
* Handles type coercion and naming differences
*/
function cliOptionsToConfig(options: CLIOptions): PartialMailDevConfig {
const config: PartialMailDevConfig = {}
// Port options (string to number)
if (options.smtp !== undefined) {
config.smtp = parseInt(options.smtp, 10)
}
if (options.web !== undefined) {
config.web = parseInt(options.web, 10)
}
if (options.outgoingPort !== undefined) {
config.outgoingPort = parseInt(options.outgoingPort, 10)
}
if (options.maxMessageSize !== undefined) {
config.maxMessageSize = parseInt(options.maxMessageSize, 10)
}
if (options.maxEmails !== undefined) {
config.maxEmails = parseInt(options.maxEmails, 10)
}
// String options
if (options.ip !== undefined) config.ip = options.ip
if (options.incomingUser !== undefined) config.incomingUser = options.incomingUser
if (options.incomingPass !== undefined) config.incomingPass = options.incomingPass
if (options.incomingCert !== undefined) config.incomingCert = options.incomingCert
if (options.incomingKey !== undefined) config.incomingKey = options.incomingKey
if (options.webIp !== undefined) config.webIp = options.webIp
if (options.webUser !== undefined) config.webUser = options.webUser
if (options.webPass !== undefined) config.webPass = options.webPass
if (options.basePathname !== undefined) config.basePathname = options.basePathname
if (options.httpsKey !== undefined) config.httpsKey = options.httpsKey
if (options.httpsCert !== undefined) config.httpsCert = options.httpsCert
if (options.outgoingHost !== undefined) config.outgoingHost = options.outgoingHost
if (options.outgoingUser !== undefined) config.outgoingUser = options.outgoingUser
if (options.outgoingPass !== undefined) config.outgoingPass = options.outgoingPass
if (options.autoRelayRules !== undefined) config.autoRelayRules = options.autoRelayRules
if (options.mailDirectory !== undefined) config.mailDirectory = options.mailDirectory
if (options.config !== undefined) config.config = options.config
// Boolean options
if (options.incomingSecure !== undefined) config.incomingSecure = options.incomingSecure
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.verbose !== undefined) config.verbose = options.verbose
if (options.silent !== undefined) config.silent = options.silent
if (options.logMailContents !== undefined)
config.logMailContents = options.logMailContents
if (options.mcp !== undefined) config.mcp = options.mcp
// Auto-relay (can be boolean or string)
if (options.autoRelay !== undefined) {
config.autoRelay = options.autoRelay
}
// Hide extensions (comma-separated string to array)
if (options.hideExtensions !== undefined) {
config.hideExtensions = options.hideExtensions
.split(',')
.map((s) => s.trim().toUpperCase())
.filter((s) => s.length > 0) as HideableExtension[]
}
return config
}
/**
* Merge partial config into base config
* Only overwrites defined values
*/
function mergeConfig(
base: MailDevConfig,
partial: PartialMailDevConfig
): MailDevConfig {
const result = { ...base }
for (const [key, value] of Object.entries(partial)) {
if (value !== undefined) {
;(result as Record<string, unknown>)[key] = value
}
}
return result
}
/**
* Resolve complete configuration from all sources
*
* Priority: CLI args > Environment vars > Config file > Defaults
*/
export async function resolveConfig(
cliOptions: CLIOptions = {}
): Promise<{ config: MailDevConfig; configFilePath: string | null }> {
// Start with defaults
let config = getDefaultConfig()
// Load config file (uses explicit path from CLI if provided)
const { config: fileConfig, filePath } = await loadConfig(cliOptions.config)
config = mergeConfig(config, fileConfig)
// Load environment variables
const envConfig = loadEnvConfig()
config = mergeConfig(config, envConfig)
// Apply CLI options (highest priority)
const cliConfig = cliOptionsToConfig(cliOptions)
config = mergeConfig(config, cliConfig)
return { config, configFilePath: filePath }
}
+342
View File
@@ -0,0 +1,342 @@
/**
* MailDev CLI Configuration Types
*
* Complete configuration interface for MailDev v3 CLI
* maintaining full backward compatibility with v2's options
*/
import type { HideableExtension } from '@maildev/smtp'
/**
* SMTP server authentication configuration
*/
export interface SMTPAuthConfig {
user: string
pass: string
}
/**
* TLS/SSL configuration for SMTP or HTTPS
*/
export interface TLSConfig {
cert: string
key: string
}
/**
* Outgoing relay server configuration
*/
export interface RelayConfig {
host: string
port: number
user?: string
pass?: string
secure?: boolean
}
/**
* Auto-relay configuration
*/
export interface AutoRelayConfig {
enabled: boolean
recipient?: string
rules?: string // Path to rules file
}
/**
* Web/API server authentication configuration
*/
export interface WebAuthConfig {
user: string
pass: string
}
/**
* Logging configuration
*/
export interface LogConfig {
verbose: boolean
silent: boolean
logMailContents: boolean
}
/**
* Complete MailDev configuration interface
*
* All 30+ options from v2 plus new v3 options
*/
export interface MailDevConfig {
// === SMTP Server Options ===
/**
* SMTP server port
* CLI: -s, --smtp <port>
* Env: MAILDEV_SMTP_PORT
* @default 1025
*/
smtp: number
/**
* IP address to bind SMTP server
* CLI: --ip <address>
* Env: MAILDEV_IP
* @default '::'
*/
ip: string
/**
* SMTP authentication username
* CLI: --incoming-user <user>
*/
incomingUser?: string
/**
* SMTP authentication password
* CLI: --incoming-pass <pass>
*/
incomingPass?: string
/**
* Enable TLS/SSL for SMTP
* CLI: --incoming-secure
*/
incomingSecure?: boolean
/**
* Path to TLS certificate for SMTP
* CLI: --incoming-cert <path>
*/
incomingCert?: string
/**
* Path to TLS key for SMTP
* CLI: --incoming-key <path>
*/
incomingKey?: string
/**
* SMTP extensions to hide
* CLI: --hide-extensions <list>
* Comma-separated: STARTTLS,PIPELINING,8BITMIME,SMTPUTF8
*/
hideExtensions?: HideableExtension[]
/**
* Maximum accepted message size in bytes. Larger messages are rejected
* before being parsed or stored. The SIZE extension is advertised so
* well-behaved clients skip oversized messages. Set to 0 to disable the
* limit.
* CLI: --max-message-size <bytes>
* Env: MAILDEV_MAX_MESSAGE_SIZE
* @default 52428800 (50 MB)
*/
maxMessageSize: number
// === Web/API Server Options ===
/**
* Web/API server port
* CLI: -w, --web <port>
* Env: MAILDEV_WEB_PORT
* @default 1080
*/
web: number
/**
* IP address to bind web server
* CLI: --web-ip <address>
* Env: MAILDEV_WEB_IP
* @default '0.0.0.0'
*/
webIp: string
/**
* Web interface HTTP auth username
* CLI: --web-user <user>
*/
webUser?: string
/**
* Web interface HTTP auth password
* CLI: --web-pass <pass>
*/
webPass?: string
/**
* Base URL pathname for web interface
* CLI: --base-pathname <path>
* @default '/'
*/
basePathname: string
/**
* Disable web interface entirely
* CLI: --disable-web
*/
disableWeb?: boolean
/**
* Enable HTTPS for web server
* CLI: --https
*/
https?: boolean
/**
* Path to HTTPS key file
* CLI: --https-key <file>
*/
httpsKey?: string
/**
* Path to HTTPS certificate file
* CLI: --https-cert <file>
*/
httpsCert?: string
// === Outgoing/Relay Options ===
/**
* Outgoing relay SMTP host
* CLI: --outgoing-host <host>
*/
outgoingHost?: string
/**
* Outgoing relay SMTP port
* CLI: --outgoing-port <port>
*/
outgoingPort?: number
/**
* Outgoing relay authentication username
* CLI: --outgoing-user <user>
*/
outgoingUser?: string
/**
* Outgoing relay authentication password
* CLI: --outgoing-pass <password>
*/
outgoingPass?: string
/**
* Use TLS/SSL for outgoing relay
* CLI: --outgoing-secure
*/
outgoingSecure?: boolean
/**
* Enable auto-relay mode
* CLI: --auto-relay [email]
* If email provided, all emails relay to that address
*/
autoRelay?: boolean | string
/**
* Path to auto-relay rules file
* CLI: --auto-relay-rules <file>
*/
autoRelayRules?: string
// === Storage Options ===
/**
* Directory for persisting emails
* CLI: --mail-directory <path>
* If not set, uses in-memory storage
*/
mailDirectory?: string
/**
* Maximum number of emails to keep (0 = unlimited, the default)
*
* Defaults to 0, preserving MailDev's historical unbounded behaviour. Set a
* positive number to cap the store: once the limit is reached the oldest
* email is dropped, and its .eml file and attachments are deleted along with
* it, so both memory use and the mail directory stay bounded.
*
* CLI: --max-emails <count>
* Env: MAILDEV_MAX_EMAILS
* @default 0
*/
maxEmails: number
// === Logging Options ===
/**
* Enable verbose logging
* CLI: -v, --verbose
*/
verbose?: boolean
/**
* Suppress all output
* CLI: --silent
*/
silent?: boolean
/**
* Log JSON representation of emails
* CLI: --log-mail-contents
*/
logMailContents?: boolean
// === v3 New Options ===
/**
* Enable MCP server at /mcp endpoint
* CLI: --mcp
* @default false
*/
mcp?: boolean
/**
* Explicit config file path
* CLI: --config <file>
*/
config?: string
}
/**
* Partial configuration (for merging)
*/
export type PartialMailDevConfig = Partial<MailDevConfig>
/**
* CLI options as parsed by Commander.js
* (may have different names/casing than config)
*/
export interface CLIOptions {
smtp?: string
ip?: string
incomingUser?: string
incomingPass?: string
incomingSecure?: boolean
incomingCert?: string
incomingKey?: string
hideExtensions?: string
maxMessageSize?: string
web?: string
webIp?: string
webUser?: string
webPass?: string
basePathname?: string
disableWeb?: boolean
https?: boolean
httpsKey?: string
httpsCert?: string
outgoingHost?: string
outgoingPort?: string
outgoingUser?: string
outgoingPass?: string
outgoingSecure?: boolean
autoRelay?: boolean | string
autoRelayRules?: string
mailDirectory?: string
maxEmails?: string
verbose?: boolean
silent?: boolean
logMailContents?: boolean
mcp?: boolean
config?: string
}
+187
View File
@@ -0,0 +1,187 @@
/**
* MailDev CLI Option Definitions
*
* All Commander.js options matching v2 exactly plus new v3 options
*/
import { Command } from 'commander'
import { DEFAULT_CONFIG } from './config/defaults.js'
/**
* Configure all CLI options on the Commander program
*
* Maintains exact compatibility with v2 CLI flags
*/
export function configureOptions(program: Command): void {
program
// === SMTP Server Options ===
// Note: defaults are NOT set here to allow env vars to work properly
// Defaults are applied in config/merge.ts via getDefaultConfig()
.option(
'-s, --smtp <port>',
`SMTP port to listen on (default: ${DEFAULT_CONFIG.smtp})`
)
.option(
'--ip <address>',
`IP address to bind SMTP server (default: ${DEFAULT_CONFIG.ip})`
)
.option(
'--incoming-user <user>',
'SMTP authentication username'
)
.option(
'--incoming-pass <password>',
'SMTP authentication password'
)
.option(
'--incoming-secure',
'Enable TLS/SSL for SMTP server'
)
.option(
'--incoming-cert <path>',
'Path to TLS certificate for SMTP'
)
.option(
'--incoming-key <path>',
'Path to TLS key for SMTP'
)
.option(
'--hide-extensions <extensions>',
'Comma-separated SMTP extensions to hide (STARTTLS,PIPELINING,8BITMIME,SMTPUTF8)'
)
.option(
'--max-message-size <bytes>',
`Maximum accepted message size in bytes, 0 to disable (default: ${DEFAULT_CONFIG.maxMessageSize})`
)
// === Web/API Server Options ===
.option(
'-w, --web <port>',
`Web/API port to listen on (default: ${DEFAULT_CONFIG.web})`
)
.option(
'--web-ip <address>',
`IP address to bind web server (default: ${DEFAULT_CONFIG.webIp})`
)
.option(
'--web-user <user>',
'Web interface HTTP basic auth username'
)
.option(
'--web-pass <password>',
'Web interface HTTP basic auth password'
)
.option(
'--base-pathname <path>',
`Base URL pathname for web interface (default: ${DEFAULT_CONFIG.basePathname})`
)
.option(
'--disable-web',
'Disable web interface entirely'
)
.option(
'--https',
'Enable HTTPS for web server'
)
.option(
'--https-key <path>',
'Path to HTTPS private key file'
)
.option(
'--https-cert <path>',
'Path to HTTPS certificate file'
)
// === Outgoing/Relay Options ===
.option(
'--outgoing-host <host>',
'Outgoing relay SMTP host'
)
.option(
'--outgoing-port <port>',
'Outgoing relay SMTP port'
)
.option(
'--outgoing-user <user>',
'Outgoing relay SMTP username'
)
.option(
'--outgoing-pass <password>',
'Outgoing relay SMTP password'
)
.option(
'--outgoing-secure',
'Use TLS/SSL for outgoing relay'
)
.option(
'--auto-relay [email]',
'Enable auto-relay mode (optionally specify recipient email)'
)
.option(
'--auto-relay-rules <path>',
'Path to auto-relay rules JSON file'
)
// === Storage Options ===
.option(
'--mail-directory <path>',
'Directory to persist emails (uses in-memory storage if not set)'
)
.option(
'--max-emails <count>',
`Maximum emails to keep; the oldest are discarded with their files (0 = unlimited) (default: ${DEFAULT_CONFIG.maxEmails})`
)
// === Logging Options ===
.option(
'-v, --verbose',
'Enable verbose logging'
)
.option(
'--silent',
'Suppress all output'
)
.option(
'--log-mail-contents',
'Log JSON representation of received emails'
)
// === v3 New Options ===
.option(
'--mcp',
'Enable MCP server at /mcp endpoint'
)
.option(
'--config <path>',
'Path to configuration file'
)
}
/**
* Environment variable documentation for help text
*/
export const ENV_VARS = `
Environment Variables:
MAILDEV_SMTP_PORT SMTP port (default: 1025)
MAILDEV_IP SMTP bind address (default: ::)
MAILDEV_WEB_PORT Web port (default: 1080)
MAILDEV_WEB_IP Web bind address (default: 0.0.0.0)
MAILDEV_INCOMING_USER SMTP username
MAILDEV_INCOMING_PASS SMTP password
MAILDEV_WEB_USER Web auth username
MAILDEV_WEB_PASS Web auth password
MAILDEV_MAIL_DIRECTORY Directory for persisting emails
MAILDEV_MAX_MESSAGE_SIZE Max accepted message size in bytes (default: 52428800)
MAILDEV_MAX_EMAILS Maximum emails to keep (0 = unlimited, the default)
`
/**
* Configuration file documentation
*/
export const CONFIG_FILES = `
Configuration Files (searched in current directory and parents):
.maildevrc.json JSON configuration
maildev.config.ts TypeScript configuration
maildev.config.js JavaScript configuration
maildev.config.mjs ES module configuration
`
+385
View File
@@ -0,0 +1,385 @@
/**
* MailDev Server Orchestrator
*
* Main class that manages all MailDev servers (Storage, SMTP, API)
*/
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { readFile } from 'node:fs/promises'
import { MemoryStorage, FileStorage, type Storage } from '@maildev/core'
import { createSMTPServer, type SMTPServer, type RelayConfig, type AutoRelayConfig, type RelayRule } from '@maildev/smtp'
import { createAPIServer, type APIServer } from '@maildev/api'
import { registerUI } from '@maildev/ui/server'
import type { MailDevConfig } from '../config/types.js'
import { Logger } from '../utils/logger.js'
/**
* Server instances managed by orchestrator
*/
export interface Servers {
storage: Storage
smtp: SMTPServer
api?: APIServer
}
/**
* Orchestrator options
*/
export interface OrchestratorOptions {
config: MailDevConfig
logger: Logger
}
/**
* Server Orchestrator
*
* Creates and manages Storage, SMTP, and API servers
*/
export class Orchestrator {
private config: MailDevConfig
private logger: Logger
private storage: Storage | null = null
private smtp: SMTPServer | null = null
private api: APIServer | null = null
private running = false
constructor(options: OrchestratorOptions) {
this.config = options.config
this.logger = options.logger
}
/**
* Start all servers
*/
async start(): Promise<Servers> {
if (this.running) {
throw new Error('Servers are already running')
}
// 1. Create and initialize storage
this.storage = await this.createStorage()
await this.storage.initialize()
this.logger.debug('Storage initialized')
// 2. Create mail directory path
const mailDir = this.config.mailDirectory || join(tmpdir(), 'maildev')
// 3. Create SMTP server
const smtpOptions: Parameters<typeof createSMTPServer>[0] = {
port: this.config.smtp,
host: this.config.ip,
storage: this.storage,
mailDir,
logger: this.config.verbose ? this.createSmtpLogger() : false,
}
if (this.config.incomingSecure !== undefined) {
smtpOptions.secure = this.config.incomingSecure
}
if (this.config.incomingCert) {
smtpOptions.certPath = this.config.incomingCert
}
if (this.config.incomingKey) {
smtpOptions.keyPath = this.config.incomingKey
}
const smtpAuth = this.getSmtpAuth()
if (smtpAuth) {
smtpOptions.auth = smtpAuth
}
if (this.config.hideExtensions) {
smtpOptions.hideExtensions = this.config.hideExtensions
}
if (this.config.maxMessageSize !== undefined) {
smtpOptions.maxMessageSize = this.config.maxMessageSize
}
this.smtp = createSMTPServer(smtpOptions)
// 4. Configure relay if outgoing host is set
if (this.config.outgoingHost) {
const relayConfig = this.getRelayConfig()
this.smtp.setupRelay(relayConfig)
this.logger.debug(`Relay configured to ${relayConfig.host}:${relayConfig.port}`)
}
// 5. Configure auto-relay if enabled
if (this.config.autoRelay) {
const autoRelayConfig = await this.getAutoRelayConfig()
this.smtp.setAutoRelay(autoRelayConfig)
this.logger.debug('Auto-relay enabled')
}
// 6. Start SMTP server
await this.smtp.start()
// Log the bound address, not the requested one, so `smtp: 0` reports the
// OS-assigned ephemeral port rather than 0.
const smtpAddress = this.smtp.getAddress()
this.logger.debug(`SMTP server started on ${smtpAddress.host}:${smtpAddress.port}`)
// 6b. Keep the on-disk mail directory bounded when a limit is set: discard
// .eml files left over from previous runs beyond the limit. A directory
// that has accumulated messages across many sessions is the usual reason a
// long-lived MailDev becomes slow to start. Opt-in via --max-emails
// (0 = unlimited, the default), so it runs before the restore below and
// bounds how much there is to load back.
if (this.config.maxEmails > 0) {
this.logger.debug(
`Keeping at most ${this.config.maxEmails} emails (use --max-emails 0 for no limit)`
)
const pruned = await this.smtp.pruneMailDir(this.config.maxEmails)
if (pruned > 0) {
this.logger.info(`Removed ${pruned} old email(s) from ${mailDir}`)
}
}
// 6c. Restore persisted emails when a mail directory is configured, so mail
// survives restarts (e.g. across pod/container restarts with a mounted
// volume). Only when explicitly persisting — the tmpdir fallback is not
// restored to avoid resurrecting mail from unrelated previous runs. Only
// the newest maxEmails are loaded; loadMailsFromDirectory bounds this
// itself, matching the prune above.
if (this.config.mailDirectory) {
await this.smtp.loadMailsFromDirectory()
this.logger.debug('Restored persisted emails from mail directory')
}
// 7. Set up email event handlers
this.setupEmailHandlers()
// 8. Create and start API server (unless disabled)
if (!this.config.disableWeb) {
// Normalize basePath: '/' should become empty string, remove trailing slashes
const basePath = this.config.basePathname === '/' ? '' : this.config.basePathname.replace(/\/$/, '')
const apiOptions: Parameters<typeof createAPIServer>[0] = {
port: this.config.web,
host: this.config.webIp,
basePath,
storage: this.storage,
smtp: this.smtp,
}
const webAuth = this.getWebAuth()
if (webAuth) {
apiOptions.auth = webAuth
}
if (this.config.verbose !== undefined) {
apiOptions.logger = this.config.verbose
}
if (this.config.https) {
apiOptions.https = true
if (this.config.httpsCert) {
apiOptions.httpsCert = this.config.httpsCert
}
if (this.config.httpsKey) {
apiOptions.httpsKey = this.config.httpsKey
}
}
if (this.config.mcp) {
apiOptions.mcp = { enabled: true }
}
this.api = createAPIServer(apiOptions)
await this.api.registerPlugins()
await registerUI(this.api.server, { basePath })
await this.api.listen()
const apiAddress = this.api.getAddress()
this.logger.debug(`API server started on ${apiAddress?.host ?? this.config.webIp}:${apiAddress?.port ?? this.config.web}`)
}
this.running = true
const servers: Servers = {
storage: this.storage,
smtp: this.smtp,
}
if (this.api) {
servers.api = this.api
}
return servers
}
/**
* Stop all servers gracefully
*/
async stop(): Promise<void> {
if (!this.running) return
this.logger.debug('Stopping servers...')
// Stop in reverse order
if (this.api) {
await this.api.stop()
this.logger.debug('API server stopped')
}
if (this.smtp) {
await this.smtp.stop()
this.logger.debug('SMTP server stopped')
}
if (this.storage) {
await this.storage.close()
this.logger.debug('Storage closed')
}
this.running = false
this.api = null
this.smtp = null
this.storage = null
}
/**
* Check if servers are running
*/
isRunning(): boolean {
return this.running
}
/**
* Get server instances
*/
getServers(): Servers | null {
if (!this.running || !this.storage || !this.smtp) {
return null
}
const servers: Servers = {
storage: this.storage,
smtp: this.smtp,
}
if (this.api) {
servers.api = this.api
}
return servers
}
/**
* Create storage instance based on config
*/
private async createStorage(): Promise<Storage> {
const maxEmails = this.config.maxEmails
if (this.config.mailDirectory) {
this.logger.debug(`Using file storage: ${this.config.mailDirectory}`)
return new FileStorage({
mailDirectory: this.config.mailDirectory,
maxEmails,
})
}
this.logger.debug('Using in-memory storage')
return new MemoryStorage({ maxEmails })
}
/**
* Get SMTP authentication config
*/
private getSmtpAuth(): { user: string; pass: string } | undefined {
if (this.config.incomingUser && this.config.incomingPass) {
return {
user: this.config.incomingUser,
pass: this.config.incomingPass,
}
}
return undefined
}
/**
* Get web authentication config
*/
private getWebAuth(): { type: 'basic' | 'none'; user?: string; pass?: string } | undefined {
if (this.config.webUser && this.config.webPass) {
return {
type: 'basic',
user: this.config.webUser,
pass: this.config.webPass,
}
}
return undefined
}
/**
* Get relay configuration
*/
private getRelayConfig(): RelayConfig {
const config: RelayConfig = {
host: this.config.outgoingHost!,
port: this.config.outgoingPort || 25,
}
if (this.config.outgoingSecure !== undefined) {
config.secure = this.config.outgoingSecure
}
if (this.config.outgoingUser && this.config.outgoingPass) {
config.auth = {
user: this.config.outgoingUser,
pass: this.config.outgoingPass,
}
}
return config
}
/**
* Get auto-relay configuration
*/
private async getAutoRelayConfig(): Promise<AutoRelayConfig> {
const config: AutoRelayConfig = {
enabled: true,
}
// If auto-relay is a string, it's the default recipient
if (typeof this.config.autoRelay === 'string') {
config.defaultRecipient = this.config.autoRelay
}
// Load rules file if specified
if (this.config.autoRelayRules) {
try {
const rulesContent = await readFile(this.config.autoRelayRules, 'utf-8')
config.rules = JSON.parse(rulesContent) as RelayRule[]
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to load auto-relay rules: ${message}`)
}
}
return config
}
/**
* Create SMTP logger adapter
*/
private createSmtpLogger() {
return {
log: (...args: unknown[]) => this.logger.debug(String(args[0]), ...args.slice(1)),
info: (...args: unknown[]) => this.logger.info(String(args[0]), ...args.slice(1)),
warn: (...args: unknown[]) => this.logger.warn(String(args[0]), ...args.slice(1)),
error: (...args: unknown[]) => this.logger.error(String(args[0]), ...args.slice(1)),
debug: (...args: unknown[]) => this.logger.debug(String(args[0]), ...args.slice(1)),
}
}
/**
* Set up email event handlers
*/
private setupEmailHandlers(): void {
if (!this.smtp) return
this.smtp.on('new', (email) => {
const from = email.from?.[0]?.address || 'unknown'
const to = email.to?.[0]?.address || 'unknown'
this.logger.email(from, to, email.subject || '(no subject)')
if (this.config.logMailContents) {
this.logger.emailContents(email)
}
})
this.smtp.on('error', (error) => {
this.logger.error(`SMTP error: ${error.message}`)
})
}
}
/**
* Create a new orchestrator instance
*/
export function createOrchestrator(options: OrchestratorOptions): Orchestrator {
return new Orchestrator(options)
}