Upstream maildev/maildev state of the touched files before f000b5e316
This commit is contained in:
1 parent
3f93b59a2e
commit
a9a83f64f1
10 files changed
+2167
-30
No files matched your search
+599
@@ -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<string, string | string[]>
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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)
|
||||
```
|
||||
@@ -0,0 +1,332 @@
|
||||
# MailDev
|
||||
|
||||
[](https://www.npmjs.com/package/maildev)
|
||||
[](https://www.npmjs.com/package/maildev)
|
||||
[](https://hub.docker.com/r/maildev/maildev)
|
||||
[](/LICENSE)
|
||||
[](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).
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user