Compare commits

...
10 Commits
Author SHA1 Message Date
dualfroz 486b300dc7 Describe this repository 2026-10-22 21:04:48 +02:00
dualfroz fd864f1379 feat: add --outgoing-ignore-tls to keep the relay off STARTTLS (#377)
The relay connection only had `secure`, so with `secure: false` nodemailer
still upgraded to STARTTLS whenever the server advertised it, and there was
no way to talk cleartext to such a server. Pass nodemailer's `ignoreTLS`
through as `ignoreTLS` on the relay config, wired to
--outgoing-ignore-tls / MAILDEV_OUTGOING_IGNORE_TLS / `outgoingIgnoreTls`.
2026-10-03 02:24:38 +02:00
upstream a9a83f64f1 Upstream maildev/maildev state of the touched files before f000b5e316 2026-10-03 02:24:38 +02:00
dualfroz 3f93b59a2e fix(smtp): skip the reverse DNS lookup of connecting clients (#104)
smtp-server reverse resolves every connecting client before it sends the
220 greeting, unless disableReverseLookup is set. MailDev never reads the
resolved hostname, and without a network the lookup does not answer, so
each connection waited for smtp-server to give up on it.
2026-10-03 02:18:43 +02:00
dualfroz 53640c6990 fix(smtp): reject the relay when the outgoing server cannot be reached
SMTPConnection only calls the connect() callback once the handshake
succeeds; a refused or timed out connection is emitted as an 'error'
event, which was only logged. relay() then never settled, so its finally
block (which now releases the email stream) never ran, auto-relay held
the incoming SMTP transaction open without a reply, and the REST relay
request hung. Reject the connect promise from the 'error' handler.
2026-10-02 15:59:20 +02:00
dualfroz 5479067fab fix(smtp): close the raw email stream when a relay does not send it (#423)
relayEmail opens a read stream on the .eml file for every relay, and only
client.send() ever consumed it. When the auto-relay rules left no
recipients, or the outgoing server failed the send (e.g. rejected a
recipient), the stream was never read or destroyed and its file
descriptor stayed open. Destroy it on the early return and in the finally
block; after a successful send it is already fully read, so that is a
no-op.
2026-10-02 15:57:49 +02:00
upstream 27e7633a30 Upstream maildev/maildev state of the touched files before 661742ab9d 2026-10-02 15:57:49 +02:00
dualfroz a10bcd3e4c fix: parse MAILDEV_HTTPS as a boolean (#294)
MAILDEV_HTTPS is listed in BOOLEAN_VARS, but envVarToConfigKey() had no
entry for `https`, so the boolean pass skipped it and the raw string from
ENV_MAPPING stayed in the config. MAILDEV_HTTPS=false then enabled HTTPS
and validation demanded httpsCert and httpsKey.
2026-10-02 15:52:08 +02:00
upstream 7f6defcdc5 Upstream maildev/maildev state of the touched files before ac15c79ff9 2026-10-02 15:52:08 +02:00
dualfroz ed77334df2 Build CID attachment URLs against the API route and base path (#582)
* fix: build CID attachment URLs against the API route and base path

replaceCidReferences rewrote cid: references to /email/:id/attachment/:file,
which matches no route: attachments are served from the API server, mounted
at ${basePath}/api. When MailDev sits under a subpath the prefix was missing
as well, which is the setup described in #439.

Emit ${basePath}/api/email/:id/attachment/:file, root-relative by default, and
thread the API server's basePath through getEmailHtml. baseUrl still prefixes
//host for a caller that passes it.

On the current code this does not change what the web UI shows for a typical
message: mailparser already inlines related parts as data: URIs, and a cid:
that survives parsing is one that matches no attachment. replaceCidReferences
is exported from @maildev/smtp, though, so the URL it builds should resolve.

Refs #439

* fix: omit the base path prefix when it resolves to the root

A caller passing basePath '/' produced //api/email/..., which a browser reads as a protocol-relative URL rather than a path. Strip the slashes first and only add a prefix when something is left.
2026-09-22 14:00:23 -04:00
25 changed files with 3310 additions and 13 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
---
'@maildev/smtp': patch
'@maildev/api': patch
---
Fix the URL that `replaceCidReferences` rewrites inline (CID) attachments to. It
built `/email/:id/attachment/:file`, which matches no route: the attachment is
served from `/api/email/:id/attachment/:file`, under the configured base path.
The URL is now root-relative and includes both parts, so it resolves at the root
and under a subpath such as `/mail`. A base path that resolves to the root (`/`)
adds no prefix, so it can no longer produce a protocol-relative `//api/email/...`.
+7
View File
@@ -0,0 +1,7 @@
---
'maildev': patch
---
Parse `MAILDEV_HTTPS` as a boolean (#294). It was passed through as the raw
string, so `MAILDEV_HTTPS=false` still enabled HTTPS and startup failed asking
for `httpsCert` and `httpsKey`.
+9
View File
@@ -0,0 +1,9 @@
---
'@maildev/smtp': patch
'maildev': patch
---
Fail the relay when the outgoing server cannot be reached. The connection
error was only logged, so the relay never finished: with auto-relay on, the
SMTP client sending to MailDev never got its reply, and the manual relay
request never answered.
+10
View File
@@ -0,0 +1,10 @@
---
'@maildev/smtp': patch
'maildev': patch
---
Close the raw `.eml` stream when a relay does not send it (#423). Every relay
that ended without sending (no recipients left after the auto-relay rules, or
an error from the outgoing server) kept one file descriptor open, so a
long-running instance eventually hit the descriptor limit and auto-relay
stopped working.
+8
View File
@@ -0,0 +1,8 @@
---
'@maildev/smtp': patch
'maildev': patch
---
Skip the reverse DNS lookup of connecting SMTP clients (#104). The resolved
hostname was never used, and without a network the lookup held back the
greeting on every connection until it timed out.
+8
View File
@@ -0,0 +1,8 @@
---
'@maildev/smtp': patch
'maildev': patch
---
Add `--outgoing-ignore-tls` / `MAILDEV_OUTGOING_IGNORE_TLS` (`outgoingIgnoreTls`,
`ignoreTLS` on the relay config) to keep the outgoing relay connection on
cleartext even when the server advertises STARTTLS (#377).
+10
View File
@@ -0,0 +1,10 @@
# Contributions to maildev/maildev
:mailbox: SMTP Server + Web Interface for viewing and testing emails during development.
This repository holds only my commits to the upstream project, with their original author, date and diff.
Each of my commits is preceded by a snapshot of the upstream files it touched, so every diff shows exactly what I changed.
## Merged pull requests
- [Build CID attachment URLs against the API route and base path](https://github.com/maildev/maildev/pull/582) (2026-09-22)
+600
View File
@@ -0,0 +1,600 @@
# 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,
outgoingIgnoreTls: false, // Never upgrade the relay connection with STARTTLS
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)
```
+2 -2
View File
@@ -457,11 +457,11 @@ export class APIServer extends EventEmitter {
`${apiPath}/email/:id/html`, `${apiPath}/email/:id/html`,
async (request, reply) => { async (request, reply) => {
const { id } = request.params const { id } = request.params
const baseUrl = request.headers.host ?? ''
try { try {
if (this.smtp) { if (this.smtp) {
const html = await this.smtp.getEmailHtml(id, { baseUrl }) // No host: the HTML is rendered for a browser already on this origin
const html = await this.smtp.getEmailHtml(id, { basePath })
if (!html) { if (!html) {
return reply.status(404).send({ error: 'Email has no HTML content' }) return reply.status(404).send({ error: 'Email has no HTML content' })
} }
+336
View File
@@ -0,0 +1,336 @@
# MailDev
[![npm](https://img.shields.io/npm/v/maildev)](https://www.npmjs.com/package/maildev)
[![npm downloads](https://img.shields.io/npm/dm/maildev)](https://www.npmjs.com/package/maildev)
[![Docker Pulls](https://img.shields.io/docker/pulls/maildev/maildev)](https://hub.docker.com/r/maildev/maildev)
[![License](https://img.shields.io/npm/l/maildev?color=white)](/LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue.svg)](https://www.typescriptlang.org/)
> [!IMPORTANT]
> MailDev 3.0 has now been released which includes a complete re-write and re-structuring of the entire project while minimizing breaking changes. Read [the upgrade guide from v2](https://maildev.github.io/maildev/docs/guides/upgrading/).
If you run into any issues, please install the latest [v2](https://github.com/maildev/maildev/tree/v2) release.
**MailDev** is a simple way to test your project's generated email during development, with an easy to use web interface that runs on your machine built on top of [Node.js](http://www.nodejs.org).
![MailDev Screenshot](https://github.com/maildev/maildev/blob/main/.github/assets/screenshot-3.0-rc.png?raw=true)
## Install
```bash
npm install -g maildev
```
## Docker Run
If you want to use MailDev with [Docker](https://www.docker.com/), you can use the
[**maildev/maildev** image on Docker Hub](https://hub.docker.com/r/maildev/maildev).
For a guide for usage with Docker,
[checkout the docs](https://github.com/maildev/maildev/blob/main/docs/docker.md).
```bash
docker run -p 1080:1080 -p 1025:1025 maildev/maildev
```
## Usage
```
Usage: maildev [options]
```
| Options | Environment variable | Description |
| -------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------- |
| `-s, --smtp <port>` | `MAILDEV_SMTP_PORT` | SMTP port to catch mail (default: 1025) |
| `-w, --web <port>` | `MAILDEV_WEB_PORT` | Port to run the Web GUI (default: 1080) |
| `--ip <ip address>` | `MAILDEV_IP` | IP Address to bind SMTP service to (default: `::`) |
| `--web-ip <ip address>` | `MAILDEV_WEB_IP` | IP Address to bind HTTP service to (default: `0.0.0.0`) |
| `--mail-directory <path>` | `MAILDEV_MAIL_DIRECTORY` | Directory for persisting mail |
| `--max-emails <count>` | `MAILDEV_MAX_EMAILS` | Maximum emails to keep; oldest are discarded with their files (default: 0 = unlimited) |
| `--https` | `MAILDEV_HTTPS` | Switch from http to https protocol |
| `--https-key <file>` | `MAILDEV_HTTPS_KEY` | The file path to the ssl private key |
| `--https-cert <file>` | `MAILDEV_HTTPS_CERT` | The file path to the ssl cert file |
| `--incoming-user <user>` | `MAILDEV_INCOMING_USER` | SMTP user for incoming mail |
| `--incoming-pass <pass>` | `MAILDEV_INCOMING_PASS` | SMTP password for incoming mail |
| `--incoming-secure` | `MAILDEV_INCOMING_SECURE` | Use SMTP SSL for incoming emails |
| `--incoming-cert <path>` | `MAILDEV_INCOMING_CERT` | Cert file location for incoming SSL |
| `--incoming-key <path>` | `MAILDEV_INCOMING_KEY` | Key file location for incoming SSL |
| `--outgoing-host <host>` | `MAILDEV_OUTGOING_HOST` | SMTP host for outgoing mail |
| `--outgoing-port <port>` | `MAILDEV_OUTGOING_PORT` | SMTP port for outgoing mail |
| `--outgoing-user <user>` | `MAILDEV_OUTGOING_USER` | SMTP user for outgoing mail |
| `--outgoing-pass <password>` | `MAILDEV_OUTGOING_PASS` | SMTP password for outgoing mail |
| `--outgoing-secure` | `MAILDEV_OUTGOING_SECURE` | Use SMTP SSL for outgoing mail |
| `--outgoing-ignore-tls` | `MAILDEV_OUTGOING_IGNORE_TLS` | Never use STARTTLS for outgoing mail, even if the server offers it |
| `--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>'
```
MailDev upgrades a non-secure relay connection with STARTTLS whenever the server offers it. Add
`--outgoing-ignore-tls` to keep it on cleartext, e.g. for a relay on port 587 that does not support TLS.
### 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
@@ -0,0 +1,212 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { loadEnvConfig, getEnvVarName } from '../../config/env.js'
describe('loadEnvConfig', () => {
const originalEnv = process.env
beforeEach(() => {
// Reset process.env before each test
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it('should return empty config when no env vars set', () => {
// Clear relevant env vars
delete process.env.MAILDEV_SMTP_PORT
delete process.env.MAILDEV_WEB_PORT
delete process.env.MAILDEV_IP
const config = loadEnvConfig()
expect(config.smtp).toBeUndefined()
expect(config.web).toBeUndefined()
})
it('should parse MAILDEV_SMTP_PORT as number', () => {
process.env.MAILDEV_SMTP_PORT = '2025'
const config = loadEnvConfig()
expect(config.smtp).toBe(2025)
expect(typeof config.smtp).toBe('number')
})
it('should parse MAILDEV_WEB_PORT as number', () => {
process.env.MAILDEV_WEB_PORT = '3080'
const config = loadEnvConfig()
expect(config.web).toBe(3080)
})
it('should parse MAILDEV_MAX_MESSAGE_SIZE as number', () => {
process.env.MAILDEV_MAX_MESSAGE_SIZE = '1048576'
const config = loadEnvConfig()
expect(config.maxMessageSize).toBe(1048576)
expect(typeof config.maxMessageSize).toBe('number')
})
it('should load MAILDEV_IP as string', () => {
process.env.MAILDEV_IP = '127.0.0.1'
const config = loadEnvConfig()
expect(config.ip).toBe('127.0.0.1')
})
it('should load MAILDEV_WEB_IP as string', () => {
process.env.MAILDEV_WEB_IP = '192.168.1.1'
const config = loadEnvConfig()
expect(config.webIp).toBe('192.168.1.1')
})
it('should load authentication env vars', () => {
process.env.MAILDEV_INCOMING_USER = 'smtp_user'
process.env.MAILDEV_INCOMING_PASS = 'smtp_pass'
process.env.MAILDEV_WEB_USER = 'web_user'
process.env.MAILDEV_WEB_PASS = 'web_pass'
const config = loadEnvConfig()
expect(config.incomingUser).toBe('smtp_user')
expect(config.incomingPass).toBe('smtp_pass')
expect(config.webUser).toBe('web_user')
expect(config.webPass).toBe('web_pass')
})
it('should load outgoing relay env vars', () => {
process.env.MAILDEV_OUTGOING_HOST = 'smtp.example.com'
process.env.MAILDEV_OUTGOING_PORT = '587'
process.env.MAILDEV_OUTGOING_USER = 'relay_user'
process.env.MAILDEV_OUTGOING_PASS = 'relay_pass'
const config = loadEnvConfig()
expect(config.outgoingHost).toBe('smtp.example.com')
expect(config.outgoingPort).toBe(587)
expect(config.outgoingUser).toBe('relay_user')
expect(config.outgoingPass).toBe('relay_pass')
})
it('should parse MAILDEV_OUTGOING_IGNORE_TLS as boolean', () => {
process.env.MAILDEV_OUTGOING_IGNORE_TLS = 'true'
expect(loadEnvConfig().outgoingIgnoreTls).toBe(true)
process.env.MAILDEV_OUTGOING_IGNORE_TLS = 'false'
expect(loadEnvConfig().outgoingIgnoreTls).toBe(false)
})
it('should load MAILDEV_MAIL_DIRECTORY', () => {
process.env.MAILDEV_MAIL_DIRECTORY = '/var/mail'
const config = loadEnvConfig()
expect(config.mailDirectory).toBe('/var/mail')
})
it('should load MAILDEV_BASE_PATHNAME', () => {
process.env.MAILDEV_BASE_PATHNAME = '/mail/'
const config = loadEnvConfig()
expect(config.basePathname).toBe('/mail/')
})
it('should ignore invalid port numbers', () => {
process.env.MAILDEV_SMTP_PORT = 'not-a-number'
const config = loadEnvConfig()
expect(config.smtp).toBeUndefined()
})
it('should handle multiple env vars simultaneously', () => {
process.env.MAILDEV_SMTP_PORT = '2025'
process.env.MAILDEV_WEB_PORT = '3080'
process.env.MAILDEV_IP = '0.0.0.0'
process.env.MAILDEV_MAIL_DIRECTORY = '/tmp/mail'
const config = loadEnvConfig()
expect(config.smtp).toBe(2025)
expect(config.web).toBe(3080)
expect(config.ip).toBe('0.0.0.0')
expect(config.mailDirectory).toBe('/tmp/mail')
})
it('should parse MAILDEV_AUTO_RELAY=true as boolean true', () => {
process.env.MAILDEV_AUTO_RELAY = 'true'
const config = loadEnvConfig()
expect(config.autoRelay).toBe(true)
})
it('should parse MAILDEV_AUTO_RELAY=1 as boolean true', () => {
process.env.MAILDEV_AUTO_RELAY = '1'
const config = loadEnvConfig()
expect(config.autoRelay).toBe(true)
})
it('should parse present-but-empty MAILDEV_AUTO_RELAY as boolean true', () => {
process.env.MAILDEV_AUTO_RELAY = ''
const config = loadEnvConfig()
expect(config.autoRelay).toBe(true)
})
it('should parse MAILDEV_AUTO_RELAY=false as boolean false', () => {
process.env.MAILDEV_AUTO_RELAY = 'false'
const config = loadEnvConfig()
expect(config.autoRelay).toBe(false)
})
it('should parse MAILDEV_AUTO_RELAY=0 as boolean false', () => {
process.env.MAILDEV_AUTO_RELAY = '0'
const config = loadEnvConfig()
expect(config.autoRelay).toBe(false)
})
it('should parse MAILDEV_AUTO_RELAY as a recipient email', () => {
process.env.MAILDEV_AUTO_RELAY = 'qa@example.com'
const config = loadEnvConfig()
expect(config.autoRelay).toBe('qa@example.com')
})
it('should load MAILDEV_AUTO_RELAY_RULES as a path string', () => {
process.env.MAILDEV_AUTO_RELAY_RULES = '/config/rules.json'
const config = loadEnvConfig()
expect(config.autoRelayRules).toBe('/config/rules.json')
})
it('should leave autoRelay and autoRelayRules unset when env vars are absent', () => {
delete process.env.MAILDEV_AUTO_RELAY
delete process.env.MAILDEV_AUTO_RELAY_RULES
const config = loadEnvConfig()
expect(config.autoRelay).toBeUndefined()
expect(config.autoRelayRules).toBeUndefined()
})
})
describe('getEnvVarName', () => {
it('should return env var name for smtp', () => {
expect(getEnvVarName('smtp')).toBe('MAILDEV_SMTP_PORT')
})
it('should return env var name for web', () => {
expect(getEnvVarName('web')).toBe('MAILDEV_WEB_PORT')
})
it('should return env var name for ip', () => {
expect(getEnvVarName('ip')).toBe('MAILDEV_IP')
})
it('should return env var name for mailDirectory', () => {
expect(getEnvVarName('mailDirectory')).toBe('MAILDEV_MAIL_DIRECTORY')
})
it('should return null for unmapped config keys', () => {
expect(getEnvVarName('mcp')).toBeNull()
expect(getEnvVarName('verbose')).toBeNull()
})
})
@@ -0,0 +1,197 @@
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,
outgoingIgnoreTls: 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)
expect(config.outgoingIgnoreTls).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)
})
})
+168
View File
@@ -0,0 +1,168 @@
/**
* MailDev Environment Variable Configuration
*
* Maps MAILDEV_* environment variables to configuration options
*/
import type { PartialMailDevConfig } from './types.js'
/**
* Environment variable to config key mapping
*/
const ENV_MAPPING: Record<string, keyof PartialMailDevConfig> = {
// SMTP
MAILDEV_SMTP_PORT: 'smtp',
MAILDEV_IP: 'ip',
MAILDEV_INCOMING_USER: 'incomingUser',
MAILDEV_INCOMING_PASS: 'incomingPass',
MAILDEV_MAX_MESSAGE_SIZE: 'maxMessageSize',
// Web/API
MAILDEV_WEB_PORT: 'web',
MAILDEV_WEB_IP: 'webIp',
MAILDEV_WEB_USER: 'webUser',
MAILDEV_WEB_PASS: 'webPass',
MAILDEV_BASE_PATHNAME: 'basePathname',
MAILDEV_HTTPS: 'https',
MAILDEV_HTTPS_CERT: 'httpsCert',
MAILDEV_HTTPS_KEY: 'httpsKey',
// Outgoing/Relay
MAILDEV_OUTGOING_HOST: 'outgoingHost',
MAILDEV_OUTGOING_PORT: 'outgoingPort',
MAILDEV_OUTGOING_USER: 'outgoingUser',
MAILDEV_OUTGOING_PASS: 'outgoingPass',
MAILDEV_AUTO_RELAY: 'autoRelay',
MAILDEV_AUTO_RELAY_RULES: 'autoRelayRules',
// Storage
MAILDEV_MAIL_DIRECTORY: 'mailDirectory',
MAILDEV_MAX_EMAILS: 'maxEmails',
// API URL (for MCP client mode)
MAILDEV_API_URL: 'webIp', // Special handling needed
}
/**
* Environment variables that should be parsed as numbers
*/
const NUMBER_VARS = new Set([
'MAILDEV_SMTP_PORT',
'MAILDEV_WEB_PORT',
'MAILDEV_OUTGOING_PORT',
'MAILDEV_MAX_MESSAGE_SIZE',
'MAILDEV_MAX_EMAILS',
])
/**
* Environment variables that should be parsed as booleans
*/
const BOOLEAN_VARS = new Set([
'MAILDEV_INCOMING_SECURE',
'MAILDEV_OUTGOING_SECURE',
'MAILDEV_OUTGOING_IGNORE_TLS',
'MAILDEV_HTTPS',
'MAILDEV_DISABLE_WEB',
'MAILDEV_MCP',
'MAILDEV_VERBOSE',
'MAILDEV_SILENT',
])
/**
* Parse boolean environment variable
*/
function parseBoolean(value: string): boolean {
return value.toLowerCase() === 'true' || value === '1'
}
/**
* MAILDEV_AUTO_RELAY is boolean | string: true/1/empty enable relay to
* each mail's own recipients; false/0 disable; any other value is the
* override recipient.
* Do not put this in BOOLEAN_VARS — that would parse an email as false.
*/
function parseAutoRelay(value: string): boolean | string {
if (value === '' || value.toLowerCase() === 'true' || value === '1') {
return true
}
if (value.toLowerCase() === 'false' || value === '0') {
return false
}
return value
}
/**
* Load configuration from environment variables
*
* @returns Partial configuration from environment
*/
export function loadEnvConfig(): PartialMailDevConfig {
const config: PartialMailDevConfig = {}
for (const [envVar, configKey] of Object.entries(ENV_MAPPING)) {
const value = process.env[envVar]
if (value === undefined) continue
if (envVar === 'MAILDEV_AUTO_RELAY') {
config.autoRelay = parseAutoRelay(value)
} else if (NUMBER_VARS.has(envVar)) {
const num = parseInt(value, 10)
if (!isNaN(num)) {
;(config as Record<string, unknown>)[configKey] = num
}
} else {
;(config as Record<string, unknown>)[configKey] = value
}
}
// Handle boolean environment variables
for (const envVar of BOOLEAN_VARS) {
const value = process.env[envVar]
if (value !== undefined) {
const key = envVarToConfigKey(envVar)
if (key) {
;(config as Record<string, unknown>)[key] = parseBoolean(value)
}
}
}
return config
}
/**
* Convert environment variable name to config key
*/
function envVarToConfigKey(envVar: string): keyof PartialMailDevConfig | null {
// Remove MAILDEV_ prefix and convert to camelCase
const withoutPrefix = envVar.replace(/^MAILDEV_/, '')
const parts = withoutPrefix.toLowerCase().split('_')
const camelCase = parts
.map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
.join('')
// Map specific env vars to config keys
const mapping: Record<string, keyof PartialMailDevConfig> = {
smtpPort: 'smtp',
webPort: 'web',
incomingSecure: 'incomingSecure',
outgoingSecure: 'outgoingSecure',
disableWeb: 'disableWeb',
verbose: 'verbose',
silent: 'silent',
mcp: 'mcp',
outgoingIgnoreTls: 'outgoingIgnoreTls',
}
return mapping[camelCase] || null
}
/**
* Get environment variable name for a config key
* (useful for error messages and documentation)
*/
export function getEnvVarName(configKey: keyof PartialMailDevConfig): string | null {
for (const [envVar, key] of Object.entries(ENV_MAPPING)) {
if (key === configKey) return envVar
}
return null
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 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.outgoingIgnoreTls !== undefined) config.outgoingIgnoreTls = options.outgoingIgnoreTls
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 }
}
+349
View File
@@ -0,0 +1,349 @@
/**
* 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
/**
* Never upgrade the outgoing relay connection with STARTTLS
* CLI: --outgoing-ignore-tls
*/
outgoingIgnoreTls?: 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
outgoingIgnoreTls?: boolean
autoRelay?: boolean | string
autoRelayRules?: string
mailDirectory?: string
maxEmails?: string
verbose?: boolean
silent?: boolean
logMailContents?: boolean
mcp?: boolean
config?: string
}
+191
View File
@@ -0,0 +1,191 @@
/**
* 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(
'--outgoing-ignore-tls',
'Never use STARTTLS for outgoing relay, even if the server offers it'
)
.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
`
+388
View File
@@ -0,0 +1,388 @@
/**
* 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.outgoingIgnoreTls !== undefined) {
config.ignoreTLS = this.config.outgoingIgnoreTls
}
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)
}
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AddressInfo } from 'node:net'
import nodemailer from 'nodemailer'
import { SMTPServer as SMTPServerLib } from 'smtp-server'
import { MemoryStorage } from '@maildev/core'
import { SMTPServer } from '../server.js'
describe('relay STARTTLS', () => {
let mailDir: string
let storage: MemoryStorage
let source: SMTPServer
let upstream: SMTPServerLib
let upstreamPort: number
// `session.secure` of every message the upstream server received
let deliveries: boolean[]
beforeEach(async () => {
mailDir = await mkdtemp(join(tmpdir(), 'maildev-relay-tls-'))
storage = new MemoryStorage()
await storage.initialize()
deliveries = []
// A plain-port relay that advertises STARTTLS, like a submission server on 587.
upstream = new SMTPServerLib({
authOptional: true,
logger: false,
onData(stream, session, callback) {
deliveries.push(session.secure)
stream.on('end', () => callback())
stream.resume()
},
})
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', () => resolve()))
upstreamPort = (upstream.server.address() as AddressInfo).port
source = new SMTPServer({ storage, mailDir, port: 0, host: '127.0.0.1' })
await source.start()
})
afterEach(async () => {
await source.stop()
await new Promise<void>((resolve) => upstream.close(() => resolve()))
await rm(mailDir, { recursive: true, force: true })
})
const send = () => {
const transport = nodemailer.createTransport({
host: '127.0.0.1',
port: source.getPort(),
secure: false,
})
return transport.sendMail({
from: 'a@example.com',
to: 'b@example.com',
subject: 'relay me',
text: 'hello',
})
}
it('upgrades the relay connection with STARTTLS when the server offers it', async () => {
source.setupRelay({ host: '127.0.0.1', port: upstreamPort, secure: false })
source.setAutoRelay({ enabled: true })
await send()
expect(deliveries).toEqual([true])
})
it('stays on cleartext with ignoreTLS even when the server offers STARTTLS', async () => {
source.setupRelay({ host: '127.0.0.1', port: upstreamPort, secure: false, ignoreTLS: true })
source.setAutoRelay({ enabled: true })
await send()
expect(deliveries).toEqual([false])
})
})
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createReadStream } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import net, { type AddressInfo } from 'node:net'
import nodemailer from 'nodemailer'
import { SMTPServer as SMTPServerLib } from 'smtp-server'
import { MemoryStorage, FileStorage, type Email } from '@maildev/core'
import { SMTPServer } from '../server.js'
import { RelayClient } from '../relay.js'
/** Grab an ephemeral free port so parallel test files don't collide. */
function freePort(): Promise<number> {
return new Promise((resolve) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const port = (srv.address() as AddressInfo).port
srv.close(() => resolve(port))
})
})
}
describe('relay delivery status', () => {
let sourceDir: string
let destDir: string
let sourceStore: MemoryStorage
let destStore: MemoryStorage
let source: SMTPServer
let dest: SMTPServer
let sourcePort: number
let destPort: number
beforeEach(async () => {
sourceDir = await mkdtemp(join(tmpdir(), 'maildev-relay-src-'))
destDir = await mkdtemp(join(tmpdir(), 'maildev-relay-dst-'))
sourceStore = new MemoryStorage()
destStore = new MemoryStorage()
await sourceStore.initialize()
await destStore.initialize()
sourcePort = await freePort()
destPort = await freePort()
dest = new SMTPServer({ storage: destStore, mailDir: destDir, port: destPort, host: '127.0.0.1' })
await dest.start()
source = new SMTPServer({ storage: sourceStore, mailDir: sourceDir, port: sourcePort, host: '127.0.0.1' })
await source.start()
source.setupRelay({ host: '127.0.0.1', port: destPort, secure: false })
source.setAutoRelay({ enabled: true })
})
afterEach(async () => {
await source.stop()
await dest.stop()
await rm(sourceDir, { recursive: true, force: true })
await rm(destDir, { recursive: true, force: true })
})
const sendTo = (port: number) => {
const transport = nodemailer.createTransport({ host: '127.0.0.1', port, secure: false })
return transport.sendMail({
from: 'a@example.com',
to: 'b@example.com',
subject: 'relay me',
text: 'hello',
})
}
it('records relayedAt and relayedTo on the stored email after an auto-relay', async () => {
await sendTo(sourcePort)
const stored = (await sourceStore.getAll())[0]!
expect(stored.relayedAt).toBeInstanceOf(Date)
expect(stored.relayedTo).toContain('b@example.com')
// The relay actually delivered it downstream.
expect(await destStore.getAll()).toHaveLength(1)
})
it('leaves relayedAt unset for an email that was never relayed', async () => {
await sendTo(destPort)
const stored = (await destStore.getAll())[0]!
expect(stored.relayedAt).toBeUndefined()
expect(stored.relayedTo).toBeUndefined()
})
it('restores relay status from disk on a fresh FileStorage-backed server', async () => {
// Use a disk-backed source so the relay status is persisted to a sidecar.
await source.stop()
const fileStore = new FileStorage({ mailDirectory: sourceDir })
await fileStore.initialize()
source = new SMTPServer({ storage: fileStore, mailDir: sourceDir, port: sourcePort, host: '127.0.0.1' })
await source.start()
source.setupRelay({ host: '127.0.0.1', port: destPort, secure: false })
source.setAutoRelay({ enabled: true })
await sendTo(sourcePort)
const id = (await fileStore.getAll())[0]!.id
expect((await fileStore.getById(id))!.relayedAt).toBeInstanceOf(Date)
// Simulate a restart: a brand-new store + server reading the same directory.
await source.stop()
const restoredStore = new FileStorage({ mailDirectory: sourceDir })
await restoredStore.initialize()
source = new SMTPServer({ storage: restoredStore, mailDir: sourceDir, port: sourcePort, host: '127.0.0.1' })
await source.loadMailsFromDirectory()
const restored = (await restoredStore.getById(id))!
expect(restored.relayedAt).toBeInstanceOf(Date)
expect(restored.relayedTo).toContain('b@example.com')
})
})
describe('relay raw email stream', () => {
let dir: string
let emlPath: string
const email = {
id: 'leak0001',
subject: 'relay me',
envelope: {
from: { address: 'a@example.com' },
to: [{ address: 'b@example.com' }],
},
} as Email
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'maildev-relay-stream-'))
emlPath = join(dir, `${email.id}.eml`)
await writeFile(emlPath, 'Subject: relay me\r\n\r\nhello\r\n')
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
it('closes the stream when rules leave no recipients', async () => {
const client = new RelayClient({ host: '127.0.0.1', port: await freePort() }, false)
client.setAutoRelay({ enabled: true, rules: [{ deny: '*' }] })
const stream = createReadStream(emlPath)
const result = await client.relay(email, stream, true)
expect(result.success).toBe(false)
expect(stream.destroyed).toBe(true)
})
it('closes the stream when the outgoing server cannot be reached', async () => {
const client = new RelayClient({ host: '127.0.0.1', port: await freePort() }, false)
const stream = createReadStream(emlPath)
const result = await client.relay(email, stream)
expect(result.success).toBe(false)
expect(result.error).toBeInstanceOf(Error)
expect(stream.destroyed).toBe(true)
})
it('closes the stream when the outgoing server rejects the recipient', async () => {
const port = await freePort()
const rejecting = new SMTPServerLib({
authOptional: true,
disabledCommands: ['STARTTLS'],
onRcptTo(_address, _session, callback) {
callback(new Error('Recipient rejected'))
},
})
await new Promise<void>((resolve) => rejecting.listen(port, '127.0.0.1', resolve))
try {
const client = new RelayClient({ host: '127.0.0.1', port }, false)
const stream = createReadStream(emlPath)
const result = await client.relay(email, stream)
expect(result.success).toBe(false)
expect(stream.destroyed).toBe(true)
} finally {
await new Promise<void>((resolve) => rejecting.close(() => resolve()))
}
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import dns from 'node:dns'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import nodemailer from 'nodemailer'
import { MemoryStorage } from '@maildev/core'
import { SMTPServer } from '../server.js'
describe('client reverse DNS lookup', () => {
let mailDir: string
let storage: MemoryStorage
let server: SMTPServer
beforeEach(async () => {
mailDir = await mkdtemp(join(tmpdir(), 'maildev-reverse-'))
storage = new MemoryStorage()
await storage.initialize()
server = new SMTPServer({ storage, mailDir, port: 0, host: '127.0.0.1' })
await server.start()
})
afterEach(async () => {
vi.restoreAllMocks()
await server.stop()
await rm(mailDir, { recursive: true, force: true })
})
// Without a network the lookup never answers, and smtp-server holds back the
// 220 greeting until it gives up on it (#104).
it('accepts mail without reverse resolving the connecting client', async () => {
const reverse = vi.spyOn(dns, 'reverse').mockImplementation(() => {})
const transport = nodemailer.createTransport({
host: '127.0.0.1',
port: server.getPort(),
secure: false,
})
await transport.sendMail({
from: 'a@example.com',
to: 'b@example.com',
subject: 'offline',
text: 'hello',
})
expect(await storage.getAll()).toHaveLength(1)
expect(reverse).not.toHaveBeenCalled()
})
})
+40 -3
View File
@@ -73,7 +73,7 @@ describe('replaceCidReferences', () => {
const result = replaceCidReferences(html, 'email1', attachments) const result = replaceCidReferences(html, 'email1', attachments)
expect(result).toContain('/email/email1/attachment/abc.png') expect(result).toContain('/api/email/email1/attachment/abc.png')
expect(result).not.toContain('cid:') expect(result).not.toContain('cid:')
}) })
@@ -85,7 +85,7 @@ describe('replaceCidReferences', () => {
const result = replaceCidReferences(html, 'email1', attachments) const result = replaceCidReferences(html, 'email1', attachments)
expect(result).toContain('/email/email1/attachment/abc.png') expect(result).toContain('/api/email/email1/attachment/abc.png')
}) })
it('should include baseUrl when provided', () => { it('should include baseUrl when provided', () => {
@@ -96,7 +96,44 @@ describe('replaceCidReferences', () => {
const result = replaceCidReferences(html, 'email1', attachments, 'example.com') const result = replaceCidReferences(html, 'email1', attachments, 'example.com')
expect(result).toContain('//example.com/email/email1/attachment/abc.png') expect(result).toContain('//example.com/api/email/email1/attachment/abc.png')
})
it('should include basePath when MailDev is mounted under a subpath', () => {
const html = '<img src="cid:image123">'
const attachments = [
{ contentId: 'image123', generatedFileName: 'abc.png' },
]
const result = replaceCidReferences(html, 'email1', attachments, undefined, '/mail')
expect(result).toContain('src="/mail/api/email/email1/attachment/abc.png"')
})
it('should normalize a basePath given with or without slashes', () => {
const html = '<img src="cid:image123">'
const attachments = [
{ contentId: 'image123', generatedFileName: 'abc.png' },
]
for (const basePath of ['mail', '/mail', '/mail/']) {
const result = replaceCidReferences(html, 'email1', attachments, undefined, basePath)
expect(result).toContain('src="/mail/api/email/email1/attachment/abc.png"')
}
})
it('should treat basePath "/" as no prefix', () => {
const html = '<img src="cid:image123">'
const attachments = [{ contentId: 'image123', generatedFileName: 'abc.png' }]
const result = replaceCidReferences(html, 'email1', attachments, undefined, '/')
expect(result).toContain('src="/api/email/email1/attachment/abc.png"')
})
it('should combine baseUrl and basePath', () => {
const html = '<img src="cid:image123">'
const attachments = [{ contentId: 'image123', generatedFileName: 'abc.png' }]
const result = replaceCidReferences(html, 'email1', attachments, 'example.com', '/mail')
expect(result).toContain('src="//example.com/mail/api/email/email1/attachment/abc.png"')
}) })
it('should replace multiple cid references', () => { it('should replace multiple cid references', () => {
+302
View File
@@ -0,0 +1,302 @@
/**
* @maildev/smtp - Relay Client
*
* Async/await based email relay client for forwarding emails
* to external SMTP servers.
*/
import SMTPConnection from 'nodemailer/lib/smtp-connection/index.js'
// @ts-expect-error - wildstring has no type definitions
import wildstring from 'wildstring'
import type { Readable } from 'node:stream'
import type { Email, EnvelopeAddress as CoreEnvelopeAddress } from '@maildev/core'
import type {
RelayConfig,
AutoRelayConfig,
RelayRule,
RelayResult,
Logger,
} from './types.js'
import { createLogger } from './logger.js'
// Configure wildstring for case-insensitive matching
wildstring.caseSensitive = false
/**
* RelayClient handles forwarding emails to external SMTP servers
*/
export class RelayClient {
private config: RelayConfig
private autoRelayConfig?: AutoRelayConfig
private logger: Logger
private client: SMTPConnection | null = null
constructor(config: RelayConfig, logger?: Logger | false) {
this.config = {
host: config.host || 'localhost',
port: config.port || (config.secure ? 465 : 25),
secure: config.secure || false,
ignoreTLS: config.ignoreTLS || false,
auth: config.auth,
}
this.logger = createLogger(logger)
}
/**
* Configure auto-relay settings
*/
setAutoRelay(config: AutoRelayConfig): void {
this.autoRelayConfig = config
if (config.enabled) {
const messages = ['Auto-Relay mode enabled']
if (config.defaultRecipient) {
messages.push(`Relaying all emails to ${config.defaultRecipient}`)
}
if (config.rules) {
messages.push(`Relay rules: ${JSON.stringify(config.rules)}`)
}
this.logger.info(messages.join(', '))
}
}
/**
* Check if auto-relay is enabled
*/
isAutoRelayEnabled(): boolean {
return this.autoRelayConfig?.enabled ?? false
}
/**
* Get the outgoing host
*/
getHost(): string {
return this.config.host
}
/**
* Relay an email to the configured SMTP server
*
* @param email - Email object to relay
* @param emailStream - Readable stream of raw email content
* @param isAutoRelay - Whether this is an auto-relay operation
* @returns Relay result
*/
async relay(
email: Email,
emailStream: Readable,
isAutoRelay = false
): Promise<RelayResult> {
let recipients = this.getRecipients(email)
// Override recipients for auto-relay with default address
if (isAutoRelay && this.autoRelayConfig?.defaultRecipient) {
recipients = [this.autoRelayConfig.defaultRecipient]
}
// Filter recipients based on auto-relay rules
if (isAutoRelay && this.autoRelayConfig?.rules) {
recipients = this.filterByRules(recipients, this.autoRelayConfig.rules)
}
// Fail silently if no recipients
if (recipients.length === 0) {
return {
success: false,
message: 'Email had no recipients after filtering',
}
}
const sender = this.getSender(email)
try {
await this.connect()
if (this.config.auth) {
await this.login()
}
await this.send(sender, recipients, emailStream)
this.logger.log('Mail Delivered:', email.subject)
return {
success: true,
message: `Relayed to ${recipients.join(', ')}`,
recipients,
}
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
this.logger.error('Relay error:', err)
return {
success: false,
error: err,
}
} finally {
await this.disconnect()
}
}
/**
* Close the client connection
*/
async close(): Promise<void> {
await this.disconnect()
}
/**
* Connect to the SMTP server
*/
private async connect(): Promise<void> {
return new Promise((resolve, reject) => {
this.client = new SMTPConnection({
port: this.config.port,
host: this.config.host,
secure: this.config.secure,
ignoreTLS: this.config.ignoreTLS,
tls: { rejectUnauthorized: false },
debug: false,
})
this.client.on('error', (err: Error) => {
this.logger.error('SMTP Connection error:', err)
})
this.client.connect((err?: Error) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
/**
* Login to the SMTP server
*/
private async login(): Promise<void> {
if (!this.client || !this.config.auth) {
throw new Error('Cannot login: client not connected or no auth configured')
}
return new Promise((resolve, reject) => {
this.client!.login(this.config.auth!, (err?: Error) => {
if (err) {
this.logger.error('Outgoing client login error:', err)
reject(err)
} else {
resolve()
}
})
})
}
/**
* Send the email
*/
private async send(
from: string,
to: string[],
stream: Readable
): Promise<void> {
if (!this.client) {
throw new Error('Cannot send: client not connected')
}
return new Promise((resolve, reject) => {
this.client!.send({ from, to }, stream, (err: Error | null) => {
if (err) {
this.logger.error('Mail Delivery Error:', err)
reject(err)
} else {
resolve()
}
})
})
}
/**
* Disconnect from the SMTP server
*/
private async disconnect(): Promise<void> {
if (this.client) {
try {
this.client.quit()
} catch {
// Ignore quit errors
}
this.client = null
}
}
/**
* Extract recipients from email envelope
*/
private getRecipients(email: Email): string[] {
const envelopeTo = email.envelope?.to
if (!envelopeTo || !Array.isArray(envelopeTo)) {
return []
}
return envelopeTo.map((addr) => this.extractAddress(addr))
}
/**
* Extract sender from email envelope
*/
private getSender(email: Email): string {
const from = email.envelope?.from
if (!from) {
return ''
}
return this.extractAddress(from)
}
/**
* Extract address string from address object
*/
private extractAddress(addr: CoreEnvelopeAddress | string): string {
if (typeof addr === 'string') {
return addr
}
return addr.address || ''
}
/**
* Filter recipients based on relay rules
*/
private filterByRules(recipients: string[], rules: RelayRule[]): string[] {
return recipients.filter((email) => this.validateAgainstRules(email, rules))
}
/**
* Validate an email address against relay rules
*/
private validateAgainstRules(email: string, rules: RelayRule[]): boolean {
// Default to allowing if no rules
let result = true
for (const rule of rules) {
const pattern = rule.allow || rule.deny || ''
const isMatch = wildstring.match(pattern, email)
if (isMatch) {
// Override previous result if this rule matches
result = !!rule.allow
}
}
return result
}
}
/**
* Create a RelayClient instance
*/
export function createRelayClient(
config: RelayConfig,
logger?: Logger | false
): RelayClient {
return new RelayClient(config, logger)
}
+12 -6
View File
@@ -45,14 +45,16 @@ export function sanitizeHtml(html: string | undefined): string | undefined {
* @param html - HTML content with cid: references * @param html - HTML content with cid: references
* @param emailId - ID of the email * @param emailId - ID of the email
* @param attachments - Array of attachments with contentId * @param attachments - Array of attachments with contentId
* @param baseUrl - Optional base URL for attachment URLs * @param baseUrl - Optional host to prefix as `//host/...`; unset gives a root-relative URL
* @param basePath - Optional pathname the API is mounted under, e.g. `/mail`
* @returns HTML with cid: references replaced with actual URLs * @returns HTML with cid: references replaced with actual URLs
*/ */
export function replaceCidReferences( export function replaceCidReferences(
html: string, html: string,
emailId: string, emailId: string,
attachments: Array<{ contentId?: string; generatedFileName: string }>, attachments: Array<{ contentId?: string; generatedFileName: string }>,
baseUrl?: string baseUrl?: string,
basePath?: string
): string { ): string {
if (!attachments || attachments.length === 0) { if (!attachments || attachments.length === 0) {
return html return html
@@ -72,7 +74,7 @@ export function replaceCidReferences(
'g' 'g'
) )
const url = buildAttachmentUrl(emailId, attachment.generatedFileName, baseUrl) const url = buildAttachmentUrl(emailId, attachment.generatedFileName, baseUrl, basePath)
const replacement = `src="${url}"` const replacement = `src="${url}"`
result = result.replace(regex, replacement) result = result.replace(regex, replacement)
@@ -82,15 +84,19 @@ export function replaceCidReferences(
} }
/** /**
* Build URL for an attachment * Build URL for an attachment. The API serves these from `${basePath}/api/email/...`,
* so both parts belong here or the src 404s.
*/ */
function buildAttachmentUrl( function buildAttachmentUrl(
emailId: string, emailId: string,
filename: string, filename: string,
baseUrl?: string baseUrl?: string,
basePath?: string
): string { ): string {
const base = baseUrl ? `//${baseUrl}` : '' const base = baseUrl ? `//${baseUrl}` : ''
return `${base}/email/${emailId}/attachment/${encodeURIComponent(filename)}` const segments = basePath ? basePath.replace(/^\/+|\/+$/g, '') : ''
const prefix = segments ? `/${segments}` : ''
return `${base}${prefix}/api/email/${emailId}/attachment/${encodeURIComponent(filename)}`
} }
/** /**
+4 -1
View File
@@ -258,7 +258,7 @@ export class SMTPServer extends EventEmitter {
// Replace CID references with actual URLs // Replace CID references with actual URLs
if (email.attachments && email.attachments.length > 0) { if (email.attachments && email.attachments.length > 0) {
html = replaceCidReferences(html, id, email.attachments, options.baseUrl) html = replaceCidReferences(html, id, email.attachments, options.baseUrl, options.basePath)
} }
return html return html
@@ -524,6 +524,9 @@ export class SMTPServer extends EventEmitter {
logger: false, logger: false,
hideSTARTTLS: true, hideSTARTTLS: true,
disabledCommands: ['AUTH'] as string[], disabledCommands: ['AUTH'] as string[],
// The client hostname is never used, and without a network the lookup
// stalls every connection before the greeting (#104).
disableReverseLookup: true,
} }
// Advertise and enforce a maximum message size. smtp-server rejects // Advertise and enforce a maximum message size. smtp-server rejects
+5 -1
View File
@@ -86,6 +86,8 @@ export interface RelayConfig {
port: number port: number
/** Enable TLS/SSL */ /** Enable TLS/SSL */
secure?: boolean secure?: boolean
/** Never upgrade a non-secure connection with STARTTLS, even if the server offers it */
ignoreTLS?: boolean
/** Authentication credentials */ /** Authentication credentials */
auth?: SMTPAuth | undefined auth?: SMTPAuth | undefined
} }
@@ -218,8 +220,10 @@ export interface RelayResult {
* HTML rendering options for email * HTML rendering options for email
*/ */
export interface HTMLRenderOptions { export interface HTMLRenderOptions {
/** Base URL for attachment URLs */ /** Host to prefix attachment URLs with; unset gives a root-relative URL */
baseUrl?: string baseUrl?: string
/** Pathname the API is mounted under, e.g. `/mail` */
basePath?: string
} }
/** /**