Compare commits
10
Commits
77bb28b844
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
486b300dc7 | ||
|
|
fd864f1379 | ||
|
|
a9a83f64f1 | ||
|
|
3f93b59a2e | ||
|
|
53640c6990 | ||
|
|
5479067fab | ||
|
|
27e7633a30 | ||
|
|
a10bcd3e4c | ||
|
|
7f6defcdc5 | ||
|
|
ed77334df2 |
No files matched your search
@@ -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/...`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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)
|
||||
```
|
||||
@@ -457,11 +457,11 @@ export class APIServer extends EventEmitter {
|
||||
`${apiPath}/email/:id/html`,
|
||||
async (request, reply) => {
|
||||
const { id } = request.params
|
||||
const baseUrl = request.headers.host ?? ''
|
||||
|
||||
try {
|
||||
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) {
|
||||
return reply.status(404).send({ error: 'Email has no HTML content' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
# MailDev
|
||||
|
||||
[](https://www.npmjs.com/package/maildev)
|
||||
[](https://www.npmjs.com/package/maildev)
|
||||
[](https://hub.docker.com/r/maildev/maildev)
|
||||
[](/LICENSE)
|
||||
[](https://www.typescriptlang.org/)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> MailDev 3.0 has now been released which includes a complete re-write and re-structuring of the entire project while minimizing breaking changes. Read [the upgrade guide from v2](https://maildev.github.io/maildev/docs/guides/upgrading/).
|
||||
|
||||
If you run into any issues, please install the latest [v2](https://github.com/maildev/maildev/tree/v2) release.
|
||||
|
||||
**MailDev** is a simple way to test your project's generated email during development, with an easy to use web interface that runs on your machine built on top of [Node.js](http://www.nodejs.org).
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install -g maildev
|
||||
```
|
||||
|
||||
## Docker Run
|
||||
|
||||
If you want to use MailDev with [Docker](https://www.docker.com/), you can use the
|
||||
[**maildev/maildev** image on Docker Hub](https://hub.docker.com/r/maildev/maildev).
|
||||
For a guide for usage with Docker,
|
||||
[checkout the docs](https://github.com/maildev/maildev/blob/main/docs/docker.md).
|
||||
|
||||
```bash
|
||||
docker run -p 1080:1080 -p 1025:1025 maildev/maildev
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
Usage: maildev [options]
|
||||
```
|
||||
|
||||
| Options | Environment variable | Description |
|
||||
| -------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `-s, --smtp <port>` | `MAILDEV_SMTP_PORT` | SMTP port to catch mail (default: 1025) |
|
||||
| `-w, --web <port>` | `MAILDEV_WEB_PORT` | Port to run the Web GUI (default: 1080) |
|
||||
| `--ip <ip address>` | `MAILDEV_IP` | IP Address to bind SMTP service to (default: `::`) |
|
||||
| `--web-ip <ip address>` | `MAILDEV_WEB_IP` | IP Address to bind HTTP service to (default: `0.0.0.0`) |
|
||||
| `--mail-directory <path>` | `MAILDEV_MAIL_DIRECTORY` | Directory for persisting mail |
|
||||
| `--max-emails <count>` | `MAILDEV_MAX_EMAILS` | Maximum emails to keep; oldest are discarded with their files (default: 0 = unlimited) |
|
||||
| `--https` | `MAILDEV_HTTPS` | Switch from http to https protocol |
|
||||
| `--https-key <file>` | `MAILDEV_HTTPS_KEY` | The file path to the ssl private key |
|
||||
| `--https-cert <file>` | `MAILDEV_HTTPS_CERT` | The file path to the ssl cert file |
|
||||
| `--incoming-user <user>` | `MAILDEV_INCOMING_USER` | SMTP user for incoming mail |
|
||||
| `--incoming-pass <pass>` | `MAILDEV_INCOMING_PASS` | SMTP password for incoming mail |
|
||||
| `--incoming-secure` | `MAILDEV_INCOMING_SECURE` | Use SMTP SSL for incoming emails |
|
||||
| `--incoming-cert <path>` | `MAILDEV_INCOMING_CERT` | Cert file location for incoming SSL |
|
||||
| `--incoming-key <path>` | `MAILDEV_INCOMING_KEY` | Key file location for incoming SSL |
|
||||
| `--outgoing-host <host>` | `MAILDEV_OUTGOING_HOST` | SMTP host for outgoing mail |
|
||||
| `--outgoing-port <port>` | `MAILDEV_OUTGOING_PORT` | SMTP port for outgoing mail |
|
||||
| `--outgoing-user <user>` | `MAILDEV_OUTGOING_USER` | SMTP user for outgoing mail |
|
||||
| `--outgoing-pass <password>` | `MAILDEV_OUTGOING_PASS` | SMTP password for outgoing mail |
|
||||
| `--outgoing-secure` | `MAILDEV_OUTGOING_SECURE` | Use SMTP SSL for outgoing mail |
|
||||
| `--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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -73,7 +73,7 @@ describe('replaceCidReferences', () => {
|
||||
|
||||
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:')
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('replaceCidReferences', () => {
|
||||
|
||||
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', () => {
|
||||
@@ -96,7 +96,44 @@ describe('replaceCidReferences', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -45,14 +45,16 @@ export function sanitizeHtml(html: string | undefined): string | undefined {
|
||||
* @param html - HTML content with cid: references
|
||||
* @param emailId - ID of the email
|
||||
* @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
|
||||
*/
|
||||
export function replaceCidReferences(
|
||||
html: string,
|
||||
emailId: string,
|
||||
attachments: Array<{ contentId?: string; generatedFileName: string }>,
|
||||
baseUrl?: string
|
||||
baseUrl?: string,
|
||||
basePath?: string
|
||||
): string {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return html
|
||||
@@ -72,7 +74,7 @@ export function replaceCidReferences(
|
||||
'g'
|
||||
)
|
||||
|
||||
const url = buildAttachmentUrl(emailId, attachment.generatedFileName, baseUrl)
|
||||
const url = buildAttachmentUrl(emailId, attachment.generatedFileName, baseUrl, basePath)
|
||||
const replacement = `src="${url}"`
|
||||
|
||||
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(
|
||||
emailId: string,
|
||||
filename: string,
|
||||
baseUrl?: string
|
||||
baseUrl?: string,
|
||||
basePath?: string
|
||||
): string {
|
||||
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)}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -258,7 +258,7 @@ export class SMTPServer extends EventEmitter {
|
||||
|
||||
// Replace CID references with actual URLs
|
||||
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
|
||||
@@ -524,6 +524,9 @@ export class SMTPServer extends EventEmitter {
|
||||
logger: false,
|
||||
hideSTARTTLS: true,
|
||||
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
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface RelayConfig {
|
||||
port: number
|
||||
/** Enable TLS/SSL */
|
||||
secure?: boolean
|
||||
/** Never upgrade a non-secure connection with STARTTLS, even if the server offers it */
|
||||
ignoreTLS?: boolean
|
||||
/** Authentication credentials */
|
||||
auth?: SMTPAuth | undefined
|
||||
}
|
||||
@@ -218,8 +220,10 @@ export interface RelayResult {
|
||||
* HTML rendering options for email
|
||||
*/
|
||||
export interface HTMLRenderOptions {
|
||||
/** Base URL for attachment URLs */
|
||||
/** Host to prefix attachment URLs with; unset gives a root-relative URL */
|
||||
baseUrl?: string
|
||||
/** Pathname the API is mounted under, e.g. `/mail` */
|
||||
basePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user