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

+332
View File
@@ -0,0 +1,332 @@
# MailDev
[![npm](https://img.shields.io/npm/v/maildev)](https://www.npmjs.com/package/maildev)
[![npm downloads](https://img.shields.io/npm/dm/maildev)](https://www.npmjs.com/package/maildev)
[![Docker Pulls](https://img.shields.io/docker/pulls/maildev/maildev)](https://hub.docker.com/r/maildev/maildev)
[![License](https://img.shields.io/npm/l/maildev?color=white)](/LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/)
> [!IMPORTANT]
> MailDev 3.0 has now been released which includes a complete re-write and re-structuring of the entire project while minimizing breaking changes. Read [the upgrade guide from v2](https://maildev.github.io/maildev/docs/guides/upgrading/).
If you run into any issues, please install the latest [v2](https://github.com/maildev/maildev/tree/v2) release.
**MailDev** is a simple way to test your project's generated email during development, with an easy to use web interface that runs on your machine built on top of [Node.js](http://www.nodejs.org).
![MailDev Screenshot](https://github.com/maildev/maildev/blob/main/.github/assets/screenshot-3.0-rc.png?raw=true)
## Install
```bash
npm install -g maildev
```
## Docker Run
If you want to use MailDev with [Docker](https://www.docker.com/), you can use the
[**maildev/maildev** image on Docker Hub](https://hub.docker.com/r/maildev/maildev).
For a guide for usage with Docker,
[checkout the docs](https://github.com/maildev/maildev/blob/main/docs/docker.md).
```bash
docker run -p 1080:1080 -p 1025:1025 maildev/maildev
```
## Usage
```
Usage: maildev [options]
```
| Options | Environment variable | Description |
| -------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------- |
| `-s, --smtp <port>` | `MAILDEV_SMTP_PORT` | SMTP port to catch mail (default: 1025) |
| `-w, --web <port>` | `MAILDEV_WEB_PORT` | Port to run the Web GUI (default: 1080) |
| `--ip <ip address>` | `MAILDEV_IP` | IP Address to bind SMTP service to (default: `::`) |
| `--web-ip <ip address>` | `MAILDEV_WEB_IP` | IP Address to bind HTTP service to (default: `0.0.0.0`) |
| `--mail-directory <path>` | `MAILDEV_MAIL_DIRECTORY` | Directory for persisting mail |
| `--max-emails <count>` | `MAILDEV_MAX_EMAILS` | Maximum emails to keep; oldest are discarded with their files (default: 0 = unlimited) |
| `--https` | `MAILDEV_HTTPS` | Switch from http to https protocol |
| `--https-key <file>` | `MAILDEV_HTTPS_KEY` | The file path to the ssl private key |
| `--https-cert <file>` | `MAILDEV_HTTPS_CERT` | The file path to the ssl cert file |
| `--incoming-user <user>` | `MAILDEV_INCOMING_USER` | SMTP user for incoming mail |
| `--incoming-pass <pass>` | `MAILDEV_INCOMING_PASS` | SMTP password for incoming mail |
| `--incoming-secure` | `MAILDEV_INCOMING_SECURE` | Use SMTP SSL for incoming emails |
| `--incoming-cert <path>` | `MAILDEV_INCOMING_CERT` | Cert file location for incoming SSL |
| `--incoming-key <path>` | `MAILDEV_INCOMING_KEY` | Key file location for incoming SSL |
| `--outgoing-host <host>` | `MAILDEV_OUTGOING_HOST` | SMTP host for outgoing mail |
| `--outgoing-port <port>` | `MAILDEV_OUTGOING_PORT` | SMTP port for outgoing mail |
| `--outgoing-user <user>` | `MAILDEV_OUTGOING_USER` | SMTP user for outgoing mail |
| `--outgoing-pass <password>` | `MAILDEV_OUTGOING_PASS` | SMTP password for outgoing mail |
| `--outgoing-secure` | `MAILDEV_OUTGOING_SECURE` | Use SMTP SSL for outgoing mail |
| `--auto-relay [email]` | `MAILDEV_AUTO_RELAY` | Use auto-relay mode. Optional relay email address |
| `--auto-relay-rules <file>` | `MAILDEV_AUTO_RELAY_RULES` | Filter rules for auto relay mode |
| `--web-user <user>` | `MAILDEV_WEB_USER` | HTTP user for GUI |
| `--web-pass <password>` | `MAILDEV_WEB_PASS` | HTTP password for GUI |
| `--base-pathname <path>` | `MAILDEV_BASE_PATHNAME` | Base path for URLs |
| `--disable-web` | `MAILDEV_DISABLE_WEB` | Disable the use of the web interface |
| `--hide-extensions <extensions>` | `MAILDEV_HIDE_EXTENSIONS` | Comma separated list of SMTP extensions to NOT advertise |
| `--max-message-size <bytes>` | `MAILDEV_MAX_MESSAGE_SIZE` | Max accepted message size in bytes; larger messages are rejected. 0 disables (default: 52428800) |
| `--mcp` | `MAILDEV_MCP` | Enable MCP server for Claude integration |
| `--config <file>` | | Path to configuration file |
| `-v, --verbose` | | Enable verbose logging |
| `--silent` | | Disable all output |
| `--log-mail-contents` | | Log a JSON representation of each incoming mail |
## Storage limit
MailDev keeps every email by default (`--max-emails 0`), preserving its
historical behaviour and keeping persisted mail durable across restarts. Set a
positive limit to cap the store: when a new email arrives and the limit is
reached, the oldest is discarded — along with its `.eml` file and attachments,
so a persisted mail directory stays bounded as well. Any backlog left in the
mail directory by earlier runs is trimmed to the same limit at startup.
```bash
maildev --max-emails 1000 # keep the newest 1000
maildev --max-emails 0 # keep everything (the default)
```
Leaving the limit unset keeps everything. Be aware that memory use and the mail
directory then grow with the inbox; with typical messages, 10,000 emails is
around 150 MB of heap, so set a limit for long-running or high-volume use.
## Configuration File
MailDev supports configuration files. Create a `maildev.config.js`, `maildev.config.ts`, or `.maildevrc.json` file:
```javascript
// maildev.config.js
export default {
smtp: 1025,
web: 1080,
verbose: true,
mcp: true,
}
```
```json
// .maildevrc.json
{
"smtp": 1025,
"web": 1080,
"verbose": true
}
```
Configuration priority: CLI args > Environment variables > Config file > Defaults
## API
MailDev can be used in your Node.js application. For more info view the
[API docs](https://github.com/maildev/maildev/blob/main/docs/api.md).
```typescript
import { MailDev } from 'maildev'
const maildev = new MailDev({
smtp: 1025,
web: 1080,
})
const { smtp } = await maildev.start()
smtp.on('new', (email) => {
console.log('New email:', email.subject)
})
// When done
await maildev.stop()
```
MailDev also has a **REST API**. For more info
[view the docs](https://github.com/maildev/maildev/blob/main/docs/rest.md).
## AI Agent Integration (MCP)
MailDev includes a Model Context Protocol (MCP) server so AI agents can work with
your dev inbox. It works with any MCP client — Claude (Desktop and Code), Cursor,
Codex, Windsurf, and more. Enable it with `--mcp`:
```bash
maildev --mcp
```
This exposes an MCP endpoint at `/mcp` that lets an agent:
- Search and retrieve emails
- Extract verification links and tokens
- Analyze email content
- Monitor email delivery
For detailed setup instructions — both transports, client config, and the
available tools, resources, and prompts — [see the MCP docs](https://github.com/maildev/maildev/blob/main/docs/mcp.md).
## Outgoing Email
MailDev optionally supports selectively relaying emails to an outgoing SMTP server. If you configure outgoing
email with the `--outgoing-*` options you can click "Relay" on an individual email to relay through MailDev out
to a real SMTP service that will actually send the email to the recipient.
Example:
```bash
maildev --outgoing-host smtp.gmail.com \
--outgoing-secure \
--outgoing-user 'you@gmail.com' \
--outgoing-pass '<pass>'
```
### Auto Relay Mode
Enabling the auto relay mode will automatically send each email to its recipient
without the need to click the "Relay" button mentioned above.
The outgoing email options are required to enable this feature.
Optionally, you can specify a single email address to which MailDev will forward
all emails instead of the original recipient. For example, using
`--auto-relay you@example.com` will forward all emails to that address
automatically.
Additionally, you can pass a valid json file with additional configuration for
which email addresses you would like to `allow` or `deny`. The last matching
rule in the array will be the rule MailDev will follow.
Example:
```bash
maildev --outgoing-host smtp.gmail.com \
--outgoing-secure \
--outgoing-user 'you@gmail.com' \
--outgoing-pass '<pass>' \
--auto-relay \
--auto-relay-rules file.json
```
Rules example file:
```json
[
{ "allow": "*" },
{ "deny": "*@test.com" },
{ "allow": "ok@test.com" },
{ "deny": "*@utah.com" },
{ "allow": "johnny@utah.com" }
]
```
This would allow `angelo@fbi.gov`, `ok@test.com`, `johnny@utah.com`, but deny
`bodhi@test.com`.
## Configure Your Project
Configure your application to send emails via port `1025` and open `localhost:1080` in your browser.
**Nodemailer (Node.js)**
```javascript
const nodemailer = require('nodemailer')
const transport = nodemailer.createTransport({
host: 'localhost',
port: 1025,
})
transport.sendMail({
from: 'sender@example.com',
to: 'recipient@example.com',
subject: 'Test Email',
text: 'Hello from MailDev!',
})
```
**Django** -- Add `EMAIL_PORT = 1025` in your settings file [[source]](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-EMAIL_PORT)
**Rails** -- config settings:
```ruby
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
address: "localhost",
port: 1025,
enable_starttls_auto: false
}
```
**Spring Boot** -- in application.properties:
```properties
spring.mail.host=localhost
spring.mail.port=1025
```
## Features
- Modern React-based web interface
- Toggle between HTML, plain text views and email headers
- Test responsive emails with resizable preview pane
- Receive and view email attachments
- Real-time updates via WebSocket
- Relay email to an upstream SMTP server
- MCP integration for Claude AI assistant
- Configuration file support
- Full TypeScript support
## Ideas
If you're using MailDev and you have a great idea, I'd love to hear it. If you're not using MailDev because it lacks a feature, I'd love to hear that too. Add an issue to the repo [here](https://github.com/maildev/maildev/issues/new).
## Contributing
Any help on MailDev would be awesome. There is plenty of room for improvement. Feel free to [create a Pull Request](https://github.com/maildev/maildev/pulls).
MailDev v3 is a TypeScript monorepo using pnpm workspaces:
```
packages/
core/ # Storage and shared types
smtp/ # SMTP server
api/ # REST API and WebSocket server
ui/ # React web interface
mcp/ # MCP server for Claude
cli/ # CLI and orchestration
```
To run **MailDev** during development:
```bash
pnpm install
pnpm dev
```
To send test emails:
```bash
node scripts/send.js
```
To run the test suite:
```bash
pnpm test
```
## [Changelog](https://github.com/maildev/maildev/releases)
## Thanks
**MailDev** is built using great open source projects including
[React](https://react.dev/),
[Fastify](https://fastify.dev/),
[Tailwind CSS](https://tailwindcss.com/),
and two great projects from [Andris Reinman](https://github.com/andris9):
[smtp-server](https://github.com/nodemailer/smtp-server)
and [mailparser](https://github.com/nodemailer/mailparser).
Many thanks to Andris as his projects are the backbone of this app and to
[MailCatcher](http://mailcatcher.me/) for the inspiration.
Additionally, thanks to all the awesome [contributors](https://github.com/maildev/maildev/graphs/contributors)
to the project.
## License
MIT
@@ -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)
}
-6
View File
@@ -100,7 +100,6 @@ export class RelayClient {
// Fail silently if no recipients
if (recipients.length === 0) {
emailStream.destroy()
return {
success: false,
message: 'Email had no recipients after filtering',
@@ -133,9 +132,6 @@ export class RelayClient {
error: err,
}
} finally {
// The stream holds an open file descriptor until it is fully read or
// destroyed; on a failed relay it was never (fully) piped to the server.
emailStream.destroy()
await this.disconnect()
}
}
@@ -160,10 +156,8 @@ export class RelayClient {
debug: false,
})
// A failed connection is reported here, not through the connect callback
this.client.on('error', (err: Error) => {
this.logger.error('SMTP Connection error:', err)
reject(err)
})
this.client.connect((err?: Error) => {