From a9a83f64f1dbb0bd51df436688c232f835e57cfa Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 3 Oct 2026 02:24:38 +0200 Subject: [PATCH] Upstream maildev/maildev state of the touched files before f000b5e316 --- docs/api.md | 599 ++++++++++++++++++ packages/cli/README.md | 332 ++++++++++ packages/cli/src/__tests__/config/env.test.ts | 23 - .../cli/src/__tests__/config/merge.test.ts | 195 ++++++ packages/cli/src/config/env.ts | 1 - packages/cli/src/config/merge.ts | 127 ++++ packages/cli/src/config/types.ts | 342 ++++++++++ packages/cli/src/options.ts | 187 ++++++ packages/cli/src/server/orchestrator.ts | 385 +++++++++++ packages/smtp/src/relay.ts | 6 - 10 files changed, 2167 insertions(+), 30 deletions(-) create mode 100644 docs/api.md create mode 100644 packages/cli/README.md create mode 100644 packages/cli/src/__tests__/config/merge.test.ts create mode 100644 packages/cli/src/config/merge.ts create mode 100644 packages/cli/src/config/types.ts create mode 100644 packages/cli/src/options.ts create mode 100644 packages/cli/src/server/orchestrator.ts diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..99c3276 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,599 @@ +# Programmatic API + +MailDev v3 provides a modern TypeScript API for embedding into your Node.js applications. The API uses async/await patterns throughout. + +## Quick Start + +```typescript +import { MailDev } from 'maildev' + +const maildev = new MailDev({ + smtp: 1025, + web: 1080, +}) + +await maildev.start() + +// Access server instances +const servers = maildev.getServers() + +// Listen for new emails +servers.smtp.on('new', (email) => { + console.log('Received:', email.subject) +}) + +// Stop when done +await maildev.stop() +``` + +## Installation + +```bash +npm install maildev +``` + +## MailDev Class + +The `MailDev` class provides the simplest way to run MailDev programmatically. + +### Constructor Options + +```typescript +import { MailDev } from 'maildev' + +const maildev = new MailDev({ + // SMTP Server + smtp: 1025, // SMTP port (default: 1025) + ip: '::', // SMTP bind address (default: '::') + + // Web/API Server + web: 1080, // Web UI port (default: 1080) + webIp: '0.0.0.0', // Web bind address (default: '0.0.0.0') + disableWeb: false, // Disable web interface + basePathname: '/', // Base path for web interface + + // Storage + mailDirectory: '/tmp/maildev', // Persist emails to disk (optional) + maxEmails: 0, // Keep at most this many emails (0 = unlimited, default) + + // Authentication + incomingUser: 'user', // SMTP auth username + incomingPass: 'pass', // SMTP auth password + webUser: 'admin', // Web UI username + webPass: 'admin', // Web UI password + + // Relay (outgoing mail) + outgoingHost: 'smtp.example.com', + outgoingPort: 587, + outgoingUser: 'user', + outgoingPass: 'pass', + outgoingSecure: true, + autoRelay: true, // Auto-forward emails + + // Logging + verbose: false, + silent: false, + logMailContents: false, + + // MCP (Claude integration) + mcp: true, // Enable MCP server at /mcp endpoint +}) +``` + +### Methods + +**start()** - Start all servers (async) +```typescript +const servers = await maildev.start() +// servers.smtp - SMTP server instance +// servers.storage - Storage instance +// servers.api - API server instance (if not disabled) +``` + +**stop()** - Stop all servers gracefully (async) +```typescript +await maildev.stop() +``` + +**isRunning()** - Check if servers are running +```typescript +if (maildev.isRunning()) { + console.log('MailDev is running') +} +``` + +**getServers()** - Get server instances +```typescript +const servers = maildev.getServers() +``` + +## Ephemeral ports + +Pass `smtp: 0` (and/or `web: 0`) to have the OS assign a free port, then read +back the port that was actually bound. This is the reliable way to run one +MailDev per worker under a parallel test runner without hand-assigning ports. + +```typescript +const maildev = new MailDev({ smtp: 0, disableWeb: true }) +const { smtp } = await maildev.start() + +const { host, port } = smtp.getAddress() +console.log(`SMTP listening on ${host}:${port}`) + +// Point your app's mail transport at `port` — e.g. nodemailer: +// nodemailer.createTransport({ host, port, secure: false }) +``` + +Both the SMTP and API servers expose the bound address: + +- `smtp.getAddress()` → `{ host, port }` (and `smtp.getPort()`) +- `servers.api?.getAddress()` → `{ host, port } | null` (and `api.getPort()`) + +The API server's accessors return `null` until it is listening. + +```typescript +const maildev = new MailDev({ smtp: 0, web: 0 }) +const { smtp, api } = await maildev.start() + +const smtpPort = smtp.getPort() // OS-assigned SMTP port +const webPort = api?.getPort() // OS-assigned web/API port +``` + +## Working with Emails + +Once MailDev is running, you can access emails through the SMTP server instance. + +### Listening for New Emails + +```typescript +const maildev = new MailDev() +const { smtp } = await maildev.start() + +smtp.on('new', (email) => { + console.log('New email received!') + console.log('From:', email.from[0].address) + console.log('To:', email.to.map(t => t.address).join(', ')) + console.log('Subject:', email.subject) + console.log('Text:', email.text) + console.log('HTML:', email.html) +}) +``` + +### Limiting how many emails are kept + +MailDev keeps every email by default (`maxEmails: 0`). Set a positive +`maxEmails` and it keeps the newest that many messages, discarding the oldest as +new mail arrives. When emails are persisted to disk, the `.eml` file and any +attachments are deleted along with the message, so the mail directory stays +bounded too. + +```typescript +const maildev = new MailDev({ + mailDirectory: '/var/mail/maildev', + maxEmails: 5000, +}) +``` + +`maxEmails: 0` (the default) keeps everything. Be aware that both memory use and +the mail directory then grow without limit: with typical messages, 10,000 emails +is around 150 MB of heap, and nothing is ever removed from disk — so set a +positive limit for long-running or high-volume use. + +### Listing a large inbox + +`smtp.getAllEmails()` materializes every email, bodies included. For listings, +use `storage.list()` instead — it returns a page of emails plus the counts +needed to paginate, so the work stays proportional to the page size rather than +the size of the store. + +```typescript +const { items, total, unread } = await servers.storage.list({ + skip: 0, + limit: 50, + search: 'welcome', // optional: subject, participants and body text + sort: 'desc', // 'desc' (default) is newest first +}) +``` + +To drop the message bodies and headers — as the REST `/api/email/summary` +endpoint and the web UI do — map the page through `toSummary`: + +```typescript +import { toSummary } from '@maildev/core' + +const summaries = items.map(toSummary) +``` + +### Getting All Emails + +```typescript +const emails = await smtp.getAllEmails() +console.log(`Total emails: ${emails.length}`) +``` + +### Getting a Single Email + +```typescript +const email = await smtp.getEmail('email-id') +console.log(email.subject) +``` + +### Getting Raw Email (EML format) + +```typescript +const stream = await smtp.getRawEmail('email-id') +stream.pipe(fs.createWriteStream('email.eml')) +``` + +### Deleting Emails + +```typescript +// Delete single email +await smtp.deleteEmail('email-id') + +// Delete all emails +await smtp.deleteAllEmails() +``` + +### Mark All as Read + +```typescript +const count = await smtp.markAllRead() +console.log(`Marked ${count} emails as read`) +``` + +### Working with Attachments + +```typescript +const email = await smtp.getEmail('email-id') + +for (const attachment of email.attachments) { + console.log(`Attachment: ${attachment.filename}`) + console.log(`Type: ${attachment.contentType}`) + console.log(`Size: ${attachment.size} bytes`) +} + +// Get attachment content +const { contentType, stream } = await smtp.getEmailAttachment('email-id', 'filename.pdf') +stream.pipe(fs.createWriteStream('filename.pdf')) +``` + +## Email Object Structure + +```typescript +interface Email { + id: string + time: Date + read: boolean + subject: string + source: string + size: number + sizeHuman: string + from: Address[] + to: Address[] + cc?: Address[] + bcc?: Address[] + calculatedBcc?: Address[] + date?: Date + html?: string + text?: string + headers: Record + inReplyTo?: string + priority?: 'high' | 'normal' | 'low' + attachments: Attachment[] + envelope: Envelope + relayedAt?: Date // when the email was last successfully relayed + relayedTo?: string[] // recipients delivered to on the last successful relay +} + +interface Address { + address: string + name?: string +} + +interface Envelope { + from: EnvelopeAddress + to: EnvelopeAddress[] + host?: string + remoteAddress?: string +} + +interface EnvelopeAddress extends Address { + args?: boolean | Record +} + +interface Attachment { + filename: string + generatedFileName: string + contentType: string + contentDisposition: 'inline' | 'attachment' + contentId?: string + size?: number + transferred?: boolean +} +``` + +## Relay (Forwarding) Emails + +MailDev can relay emails to a real SMTP server. + +### Manual Relay + +```typescript +const maildev = new MailDev({ + outgoingHost: 'smtp.gmail.com', + outgoingPort: 587, + outgoingUser: 'you@gmail.com', + outgoingPass: 'app-password', + outgoingSecure: true, +}) + +const { smtp } = await maildev.start() + +// Relay a specific email +smtp.on('new', async (email) => { + if (email.to.some(t => t.address === 'important@example.com')) { + await smtp.relayEmail(email.id) + console.log('Email relayed!') + } +}) +``` + +After a successful relay (manual or auto) the email records `relayedAt` and +`relayedTo`, so you can tell whether and where a message was delivered: + +```typescript +const email = await smtp.getEmail('email-id') +if (email.relayedAt) { + console.log(`Relayed at ${email.relayedAt} to ${email.relayedTo?.join(', ')}`) +} +``` + +With `FileStorage` this status is persisted to disk and restored on startup; +with in-memory storage it lives only for the lifetime of the process. + +### Auto-Relay + +```typescript +const maildev = new MailDev({ + outgoingHost: 'smtp.example.com', + outgoingPort: 587, + outgoingUser: 'user', + outgoingPass: 'pass', + autoRelay: true, // Relay all emails automatically +}) +``` + +### Auto-Relay to Specific Address + +```typescript +const maildev = new MailDev({ + outgoingHost: 'smtp.example.com', + outgoingPort: 587, + outgoingUser: 'user', + outgoingPass: 'pass', + autoRelay: 'catch-all@example.com', // Override recipient +}) +``` + +## Events + +The SMTP server emits the following events: + +### 'new' +Emitted when a new email is received. + +```typescript +smtp.on('new', (email: Email) => { + console.log('New email:', email.subject) +}) +``` + +### 'delete' +Emitted when an email is deleted. + +```typescript +smtp.on('delete', (data: { id: string }) => { + console.log('Deleted email:', data.id) +}) +``` + +### 'error' +Emitted on server errors. + +```typescript +smtp.on('error', (error: Error) => { + console.error('SMTP error:', error.message) +}) +``` + +### 'close' +Emitted when the server is closed. + +```typescript +smtp.on('close', () => { + console.log('SMTP server closed') +}) +``` + +## Advanced Usage + +### Using Individual Packages + +For more control, you can use the underlying packages directly. + +```typescript +import { MemoryStorage } from '@maildev/core' +import { createSMTPServer } from '@maildev/smtp' +import { createAPIServer } from '@maildev/api' + +// Create storage +const storage = new MemoryStorage() +await storage.initialize() + +// Create SMTP server +const smtp = createSMTPServer({ + port: 1025, + host: '::', + storage, + mailDir: '/tmp/maildev', +}) + +await smtp.start() + +// Create API server +const api = createAPIServer({ + port: 1080, + storage, + smtp, + mcp: { enabled: true }, +}) + +await api.start() +``` + +### Using FileStorage for Persistence + +```typescript +import { FileStorage } from '@maildev/core' + +const storage = new FileStorage({ + mailDirectory: '/var/mail/maildev', + maxEmails: 1000, // optional: cap the store (0/omitted = unlimited) +}) +await storage.initialize() +``` + +When `maxEmails` is exceeded, the oldest email is dropped and its files are +deleted. To clean up anything else you wrote alongside an email, register an +evict handler — `save()` awaits it, so once it resolves the email is fully gone: + +```typescript +storage.onEvicted(async (email) => { + await removeMyIndexEntry(email.id) +}) +``` + +### Custom Logger + +```typescript +import { MailDev, createLogger } from 'maildev' + +const logger = createLogger({ + verbose: true, + silent: false, +}) + +// The MailDev class uses the logger internally +const maildev = new MailDev({ + verbose: true, +}) +``` + +### Middleware Integration + +You can run MailDev behind a proxy or within an existing Express/Fastify app: + +```typescript +const maildev = new MailDev({ + basePathname: '/maildev', + web: 3001, +}) + +await maildev.start() +// MailDev UI now available at http://localhost:3001/maildev +``` + +Then proxy requests to MailDev: + +```typescript +import express from 'express' +import { createProxyMiddleware } from 'http-proxy-middleware' + +const app = express() + +app.use('/maildev', createProxyMiddleware({ + target: 'http://localhost:3001', + ws: true, +})) + +app.listen(3000) +// MailDev accessible at http://localhost:3000/maildev +``` + +## TypeScript Support + +MailDev v3 is written in TypeScript and exports all types: + +```typescript +import type { + MailDevConfig, + Email, + EmailSummary, + Address, + Attachment, + Storage, + ListOptions, + ListResult, +} from 'maildev' + +import type { + SMTPServer, + SMTPServerOptions, + RelayConfig, +} from '@maildev/smtp' + +import type { + APIServer, + APIServerOptions, +} from '@maildev/api' +``` + +## Migration from v2 + +### Key Changes + +1. **Promise-based API**: All methods are now async/await +2. **No callbacks**: Replace callback patterns with promises +3. **Package structure**: Core functionality split into `@maildev/core`, `@maildev/smtp`, `@maildev/api` +4. **TypeScript**: Full type definitions included +5. **Events unchanged**: Event names and payloads are compatible + +### Example Migration + +**v2 (callbacks):** +```javascript +const MailDev = require('maildev') + +const maildev = new MailDev() + +maildev.listen(function(err) { + if (err) throw err + console.log('MailDev running') +}) + +maildev.on('new', function(email) { + console.log('New email:', email.subject) +}) + +maildev.getAllEmail(function(err, emails) { + console.log('Total:', emails.length) +}) +``` + +**v3 (async/await):** +```typescript +import { MailDev } from 'maildev' + +const maildev = new MailDev() + +const { smtp } = await maildev.start() +console.log('MailDev running') + +smtp.on('new', (email) => { + console.log('New email:', email.subject) +}) + +const emails = await smtp.getAllEmails() +console.log('Total:', emails.length) +``` diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..3afc508 --- /dev/null +++ b/packages/cli/README.md @@ -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 ` | `MAILDEV_SMTP_PORT` | SMTP port to catch mail (default: 1025) | +| `-w, --web ` | `MAILDEV_WEB_PORT` | Port to run the Web GUI (default: 1080) | +| `--ip ` | `MAILDEV_IP` | IP Address to bind SMTP service to (default: `::`) | +| `--web-ip ` | `MAILDEV_WEB_IP` | IP Address to bind HTTP service to (default: `0.0.0.0`) | +| `--mail-directory ` | `MAILDEV_MAIL_DIRECTORY` | Directory for persisting mail | +| `--max-emails ` | `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 ` | `MAILDEV_HTTPS_KEY` | The file path to the ssl private key | +| `--https-cert ` | `MAILDEV_HTTPS_CERT` | The file path to the ssl cert file | +| `--incoming-user ` | `MAILDEV_INCOMING_USER` | SMTP user for incoming mail | +| `--incoming-pass ` | `MAILDEV_INCOMING_PASS` | SMTP password for incoming mail | +| `--incoming-secure` | `MAILDEV_INCOMING_SECURE` | Use SMTP SSL for incoming emails | +| `--incoming-cert ` | `MAILDEV_INCOMING_CERT` | Cert file location for incoming SSL | +| `--incoming-key ` | `MAILDEV_INCOMING_KEY` | Key file location for incoming SSL | +| `--outgoing-host ` | `MAILDEV_OUTGOING_HOST` | SMTP host for outgoing mail | +| `--outgoing-port ` | `MAILDEV_OUTGOING_PORT` | SMTP port for outgoing mail | +| `--outgoing-user ` | `MAILDEV_OUTGOING_USER` | SMTP user for outgoing mail | +| `--outgoing-pass ` | `MAILDEV_OUTGOING_PASS` | SMTP password for outgoing mail | +| `--outgoing-secure` | `MAILDEV_OUTGOING_SECURE` | Use SMTP SSL for outgoing mail | +| `--auto-relay [email]` | `MAILDEV_AUTO_RELAY` | Use auto-relay mode. Optional relay email address | +| `--auto-relay-rules ` | `MAILDEV_AUTO_RELAY_RULES` | Filter rules for auto relay mode | +| `--web-user ` | `MAILDEV_WEB_USER` | HTTP user for GUI | +| `--web-pass ` | `MAILDEV_WEB_PASS` | HTTP password for GUI | +| `--base-pathname ` | `MAILDEV_BASE_PATHNAME` | Base path for URLs | +| `--disable-web` | `MAILDEV_DISABLE_WEB` | Disable the use of the web interface | +| `--hide-extensions ` | `MAILDEV_HIDE_EXTENSIONS` | Comma separated list of SMTP extensions to NOT advertise | +| `--max-message-size ` | `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 ` | | 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 '' +``` + +### 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 '' \ + --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 diff --git a/packages/cli/src/__tests__/config/env.test.ts b/packages/cli/src/__tests__/config/env.test.ts index 49bc551..95b048e 100644 --- a/packages/cli/src/__tests__/config/env.test.ts +++ b/packages/cli/src/__tests__/config/env.test.ts @@ -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' diff --git a/packages/cli/src/__tests__/config/merge.test.ts b/packages/cli/src/__tests__/config/merge.test.ts new file mode 100644 index 0000000..7849125 --- /dev/null +++ b/packages/cli/src/__tests__/config/merge.test.ts @@ -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) + }) +}) diff --git a/packages/cli/src/config/env.ts b/packages/cli/src/config/env.ts index 8ac3e2d..404d0a5 100644 --- a/packages/cli/src/config/env.ts +++ b/packages/cli/src/config/env.ts @@ -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', diff --git a/packages/cli/src/config/merge.ts b/packages/cli/src/config/merge.ts new file mode 100644 index 0000000..c0a77c1 --- /dev/null +++ b/packages/cli/src/config/merge.ts @@ -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)[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 } +} diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts new file mode 100644 index 0000000..4db7d2b --- /dev/null +++ b/packages/cli/src/config/types.ts @@ -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 + * Env: MAILDEV_SMTP_PORT + * @default 1025 + */ + smtp: number + + /** + * IP address to bind SMTP server + * CLI: --ip
+ * Env: MAILDEV_IP + * @default '::' + */ + ip: string + + /** + * SMTP authentication username + * CLI: --incoming-user + */ + incomingUser?: string + + /** + * SMTP authentication password + * CLI: --incoming-pass + */ + incomingPass?: string + + /** + * Enable TLS/SSL for SMTP + * CLI: --incoming-secure + */ + incomingSecure?: boolean + + /** + * Path to TLS certificate for SMTP + * CLI: --incoming-cert + */ + incomingCert?: string + + /** + * Path to TLS key for SMTP + * CLI: --incoming-key + */ + incomingKey?: string + + /** + * SMTP extensions to hide + * CLI: --hide-extensions + * 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 + * Env: MAILDEV_MAX_MESSAGE_SIZE + * @default 52428800 (50 MB) + */ + maxMessageSize: number + + // === Web/API Server Options === + + /** + * Web/API server port + * CLI: -w, --web + * Env: MAILDEV_WEB_PORT + * @default 1080 + */ + web: number + + /** + * IP address to bind web server + * CLI: --web-ip
+ * Env: MAILDEV_WEB_IP + * @default '0.0.0.0' + */ + webIp: string + + /** + * Web interface HTTP auth username + * CLI: --web-user + */ + webUser?: string + + /** + * Web interface HTTP auth password + * CLI: --web-pass + */ + webPass?: string + + /** + * Base URL pathname for web interface + * CLI: --base-pathname + * @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 + */ + httpsKey?: string + + /** + * Path to HTTPS certificate file + * CLI: --https-cert + */ + httpsCert?: string + + // === Outgoing/Relay Options === + + /** + * Outgoing relay SMTP host + * CLI: --outgoing-host + */ + outgoingHost?: string + + /** + * Outgoing relay SMTP port + * CLI: --outgoing-port + */ + outgoingPort?: number + + /** + * Outgoing relay authentication username + * CLI: --outgoing-user + */ + outgoingUser?: string + + /** + * Outgoing relay authentication password + * CLI: --outgoing-pass + */ + 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 + */ + autoRelayRules?: string + + // === Storage Options === + + /** + * Directory for persisting emails + * CLI: --mail-directory + * 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 + * 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 + */ + config?: string +} + +/** + * Partial configuration (for merging) + */ +export type PartialMailDevConfig = Partial + +/** + * 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 +} diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts new file mode 100644 index 0000000..63f1f9f --- /dev/null +++ b/packages/cli/src/options.ts @@ -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 ', + `SMTP port to listen on (default: ${DEFAULT_CONFIG.smtp})` + ) + .option( + '--ip
', + `IP address to bind SMTP server (default: ${DEFAULT_CONFIG.ip})` + ) + .option( + '--incoming-user ', + 'SMTP authentication username' + ) + .option( + '--incoming-pass ', + 'SMTP authentication password' + ) + .option( + '--incoming-secure', + 'Enable TLS/SSL for SMTP server' + ) + .option( + '--incoming-cert ', + 'Path to TLS certificate for SMTP' + ) + .option( + '--incoming-key ', + 'Path to TLS key for SMTP' + ) + .option( + '--hide-extensions ', + 'Comma-separated SMTP extensions to hide (STARTTLS,PIPELINING,8BITMIME,SMTPUTF8)' + ) + .option( + '--max-message-size ', + `Maximum accepted message size in bytes, 0 to disable (default: ${DEFAULT_CONFIG.maxMessageSize})` + ) + + // === Web/API Server Options === + .option( + '-w, --web ', + `Web/API port to listen on (default: ${DEFAULT_CONFIG.web})` + ) + .option( + '--web-ip
', + `IP address to bind web server (default: ${DEFAULT_CONFIG.webIp})` + ) + .option( + '--web-user ', + 'Web interface HTTP basic auth username' + ) + .option( + '--web-pass ', + 'Web interface HTTP basic auth password' + ) + .option( + '--base-pathname ', + `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 to HTTPS private key file' + ) + .option( + '--https-cert ', + 'Path to HTTPS certificate file' + ) + + // === Outgoing/Relay Options === + .option( + '--outgoing-host ', + 'Outgoing relay SMTP host' + ) + .option( + '--outgoing-port ', + 'Outgoing relay SMTP port' + ) + .option( + '--outgoing-user ', + 'Outgoing relay SMTP username' + ) + .option( + '--outgoing-pass ', + '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 to auto-relay rules JSON file' + ) + + // === Storage Options === + .option( + '--mail-directory ', + 'Directory to persist emails (uses in-memory storage if not set)' + ) + .option( + '--max-emails ', + `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 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 +` diff --git a/packages/cli/src/server/orchestrator.ts b/packages/cli/src/server/orchestrator.ts new file mode 100644 index 0000000..ef776ad --- /dev/null +++ b/packages/cli/src/server/orchestrator.ts @@ -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 { + 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[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[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 { + 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 { + 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 { + 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) +} diff --git a/packages/smtp/src/relay.ts b/packages/smtp/src/relay.ts index 2cd9f33..7fe5850 100644 --- a/packages/smtp/src/relay.ts +++ b/packages/smtp/src/relay.ts @@ -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) => {