Upstream maildev/maildev state of the touched files before ec889a40ce

This commit is contained in:
upstream committed 2026-09-11 23:33:43 +02:00
commit 1ce2ff856b
5 files changed
+2325

No files matched your search

@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import { sanitizeHtml, replaceCidReferences } from '../sanitize.js'
describe('sanitizeHtml', () => {
it('should return undefined for undefined input', () => {
expect(sanitizeHtml(undefined)).toBeUndefined()
})
it('should return undefined for empty string', () => {
expect(sanitizeHtml('')).toBeUndefined()
})
it('should sanitize basic HTML', () => {
const html = '<p>Hello World</p>'
const result = sanitizeHtml(html)
expect(result).toContain('Hello World')
})
it('should remove script tags', () => {
const html = '<p>Safe</p><script>alert("xss")</script>'
const result = sanitizeHtml(html)
expect(result).not.toContain('<script>')
expect(result).not.toContain('alert')
expect(result).toContain('Safe')
})
it('should remove onclick handlers', () => {
const html = '<button onclick="alert(1)">Click</button>'
const result = sanitizeHtml(html)
expect(result).not.toContain('onclick')
})
it('should preserve link elements', () => {
const html = '<link rel="stylesheet" href="style.css">'
const result = sanitizeHtml(html)
expect(result).toContain('<link')
})
it('should preserve target attributes on links', () => {
const html = '<a href="https://example.com" target="_blank">Link</a>'
const result = sanitizeHtml(html)
expect(result).toContain('target="_blank"')
})
it('should preserve full document structure', () => {
const html = '<html><head><title>Test</title></head><body><p>Content</p></body></html>'
const result = sanitizeHtml(html)
expect(result).toContain('<html>')
expect(result).toContain('<head>')
expect(result).toContain('<body>')
})
})
describe('replaceCidReferences', () => {
it('should return original HTML when no attachments', () => {
const html = '<img src="image.png">'
const result = replaceCidReferences(html, 'email1', [])
expect(result).toBe(html)
})
it('should replace cid references with URLs', () => {
const html = '<img src="cid:image123">'
const attachments = [
{ contentId: 'image123', generatedFileName: 'abc.png' },
]
const result = replaceCidReferences(html, 'email1', attachments)
expect(result).toContain('/email/email1/attachment/abc.png')
expect(result).not.toContain('cid:')
})
it('should handle single quotes around cid', () => {
const html = "<img src='cid:image123'>"
const attachments = [
{ contentId: 'image123', generatedFileName: 'abc.png' },
]
const result = replaceCidReferences(html, 'email1', attachments)
expect(result).toContain('/email/email1/attachment/abc.png')
})
it('should include baseUrl when provided', () => {
const html = '<img src="cid:image123">'
const attachments = [
{ contentId: 'image123', generatedFileName: 'abc.png' },
]
const result = replaceCidReferences(html, 'email1', attachments, 'example.com')
expect(result).toContain('//example.com/email/email1/attachment/abc.png')
})
it('should replace multiple cid references', () => {
const html = '<img src="cid:img1"><img src="cid:img2">'
const attachments = [
{ contentId: 'img1', generatedFileName: 'a.png' },
{ contentId: 'img2', generatedFileName: 'b.png' },
]
const result = replaceCidReferences(html, 'email1', attachments)
expect(result).toContain('attachment/a.png')
expect(result).toContain('attachment/b.png')
expect(result).not.toContain('cid:')
})
it('should URL encode attachment filenames', () => {
const html = '<img src="cid:image123">'
const attachments = [
{ contentId: 'image123', generatedFileName: 'file name.png' },
]
const result = replaceCidReferences(html, 'email1', attachments)
expect(result).toContain('file%20name.png')
})
it('should skip attachments without contentId', () => {
const html = '<img src="cid:image123">'
const attachments = [
{ generatedFileName: 'other.png' },
{ contentId: 'image123', generatedFileName: 'abc.png' },
]
const result = replaceCidReferences(html, 'email1', attachments)
expect(result).toContain('abc.png')
expect(result).not.toContain('other.png')
})
})
+101
View File
@@ -0,0 +1,101 @@
/**
* @maildev/smtp - HTML Sanitization
*
* Safe HTML sanitization for email content using DOMPurify.
*/
import createDOMPurify from 'dompurify'
import { JSDOM } from 'jsdom'
// Create a window object for DOMPurify
const { window } = new JSDOM('')
const DOMPurify = createDOMPurify(window)
/**
* Sanitize HTML content from email for safe display
*
* This uses DOMPurify with settings appropriate for email content:
* - Preserves the full HTML document structure (html, head, body)
* - Allows link elements for external stylesheets
* - Preserves target attributes on links
* - Disables DOM clobbering sanitization to preserve form id/name attributes
*
* @param html - Raw HTML content from email
* @returns Sanitized HTML safe for display
*/
export function sanitizeHtml(html: string | undefined): string | undefined {
if (!html) {
return undefined
}
return DOMPurify.sanitize(html, {
WHOLE_DOCUMENT: true, // Preserve html, head, body elements
SANITIZE_DOM: false, // Ignore DOM clobbering to preserve form id/name attributes
ADD_TAGS: ['link'], // Allow link element to preserve external style sheets
ADD_ATTR: ['target'], // Preserve explicit target attributes on links
})
}
/**
* Replace CID references in HTML with actual URLs
*
* Embedded attachments in emails use cid: URLs that need to be
* replaced with actual accessible URLs.
*
* @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
* @returns HTML with cid: references replaced with actual URLs
*/
export function replaceCidReferences(
html: string,
emailId: string,
attachments: Array<{ contentId?: string; generatedFileName: string }>,
baseUrl?: string
): string {
if (!attachments || attachments.length === 0) {
return html
}
let result = html
// Find attachments with content IDs (embedded attachments)
const embeddedAttachments = attachments.filter((a) => a.contentId)
for (const attachment of embeddedAttachments) {
if (!attachment.contentId) continue
// Match src="cid:xxx" or src='cid:xxx'
const regex = new RegExp(
`src=("|')cid:${escapeRegExp(attachment.contentId)}("|')`,
'g'
)
const url = buildAttachmentUrl(emailId, attachment.generatedFileName, baseUrl)
const replacement = `src="${url}"`
result = result.replace(regex, replacement)
}
return result
}
/**
* Build URL for an attachment
*/
function buildAttachmentUrl(
emailId: string,
filename: string,
baseUrl?: string
): string {
const base = baseUrl ? `//${baseUrl}` : ''
return `${base}/email/${emailId}/attachment/${encodeURIComponent(filename)}`
}
/**
* Escape special regex characters in a string
*/
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
+964
View File
@@ -0,0 +1,964 @@
/**
* @maildev/smtp - SMTP Server
*
* Modern TypeScript SMTP server with async/await patterns.
*/
import { SMTPServer as SMTPServerLib } from 'smtp-server'
import type { SMTPServerSession, SMTPServerDataStream } from 'smtp-server'
import { EventEmitter } from 'node:events'
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { rm, readdir, readFile, writeFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { PassThrough, Transform, type Readable } from 'node:stream'
import { formatBytes, calculateBcc, makeId, mapLimit, type Storage, type Email, type EmailMetadata, type Attachment } from '@maildev/core'
import { parseEmailStream, parseEmailBuffer } from './parser.js'
import { createAuthCallback } from './auth.js'
import { createLogger } from './logger.js'
import { sanitizeHtml, replaceCidReferences } from './sanitize.js'
import { transformAttachment, getAttachmentFilePath, getAttachmentDir } from './attachments.js'
import { RelayClient } from './relay.js'
import type {
SMTPServerOptions,
StoredEnvelope,
ParsedEmail,
ParsedAttachment,
HideableExtension,
RelayConfig,
AutoRelayConfig,
Logger,
HTMLRenderOptions,
EmailDownload,
EnvelopeAddress,
TransformedAttachment,
} from './types.js'
const DEFAULT_PORT = 1025
const DEFAULT_HOST = '::'
/** Cap on concurrent filesystem operations, to stay well clear of EMFILE */
const FS_CONCURRENCY = 16
/** How many emails to parse before saving them, when restoring from disk */
const RESTORE_CHUNK_SIZE = 64
/** An .eml file found in the mail directory */
interface EmlFile {
/** Email ID, i.e. the filename without the .eml suffix */
id: string
/** Last modified time, used to order the directory newest-first */
mtimeMs: number
}
const HIDEABLE_EXTENSIONS: HideableExtension[] = [
'STARTTLS',
'PIPELINING',
'8BITMIME',
'SMTPUTF8',
]
/**
* MailDev SMTP Server
*
* Receives emails via SMTP and stores them using the provided storage backend.
*/
export class SMTPServer extends EventEmitter {
private smtp: SMTPServerLib | null = null
private storage: Storage
private mailDir: string
private port: number
private host: string
private options: SMTPServerOptions
private logger: Logger
private relayClient: RelayClient | null = null
private unsubscribeEvict: () => void
constructor(options: SMTPServerOptions) {
super()
this.storage = options.storage
this.mailDir = options.mailDir
// Use ?? so an explicit `port: 0` (bind an OS-assigned ephemeral port) is
// honored rather than falling back to the default — `0 || DEFAULT` would
// treat a meaningful 0 as absent. The real port is read back from the
// listening socket in start() and exposed via getAddress()/getPort().
this.port = options.port ?? DEFAULT_PORT
this.host = options.host || DEFAULT_HOST
this.options = options
this.logger = createLogger(options.logger)
// Ensure mail directory exists
this.ensureMailDir()
// We wrote the .eml and attachments, so we clean them up when the store
// drops an email to stay within its limit. Otherwise the store stays
// bounded while the directory on disk grows forever.
this.unsubscribeEvict = this.storage.onEvicted((email) => this.removeEmailFiles(email.id))
}
/**
* Start the SMTP server
*/
async start(): Promise<void> {
// Build smtp-server configuration
const config = this.buildServerConfig()
this.smtp = new SMTPServerLib(config)
// Node's server.listen() callback is the 'listening' listener, not an
// error-first callback. Bind failures (EADDRINUSE, EACCES, …) arrive on
// 'error' instead, so reject start() from that event until we are listening.
return new Promise((resolve, reject) => {
const onStartupError = (err: Error) => {
reject(err)
}
this.smtp!.once('error', onStartupError)
this.smtp!.listen(this.port, this.host, (err?: Error) => {
if (err) {
this.smtp!.off('error', onStartupError)
reject(err)
return
}
this.smtp!.off('error', onStartupError)
this.smtp!.on('error', (e: NodeJS.ErrnoException) => {
this.handleServerError(e)
})
// Record the port the OS actually bound. When `port: 0` was requested
// this is the assigned ephemeral port; getAddress()/getPort() then let
// the caller discover it (e.g. one MailDev per parallel test worker).
const address = this.smtp!.server.address()
if (address && typeof address === 'object') {
this.port = address.port
}
const printHost = this.host === '::' ? 'localhost' : this.host
this.logger.info(`SMTP Server running at ${printHost}:${this.port}`)
resolve()
})
})
}
/**
* Get the address the SMTP server is bound to
*
* After start(), the port reflects the actual bound port — so a server
* started with `port: 0` reports the OS-assigned ephemeral port here.
*/
getAddress(): { host: string; port: number } {
return { host: this.host, port: this.port }
}
/**
* Get the port the SMTP server is bound to
*
* After start() with `port: 0`, this is the OS-assigned ephemeral port.
*/
getPort(): number {
return this.port
}
/**
* Stop the SMTP server
*/
async stop(): Promise<void> {
this.emit('close')
this.unsubscribeEvict()
if (this.relayClient) {
await this.relayClient.close()
}
return new Promise((resolve) => {
if (this.smtp) {
this.smtp.close(() => resolve())
} else {
resolve()
}
})
}
/**
* Configure outgoing relay
*/
setupRelay(config: RelayConfig): void {
this.relayClient = new RelayClient(config, this.options.logger)
this.logger.info(
`Outgoing SMTP configured: ${config.host}:${config.port} (secure: ${config.secure ? 'yes' : 'no'})`
)
}
/**
* Check if relay is enabled
*/
isRelayEnabled(): boolean {
return this.relayClient !== null
}
/**
* Get the outgoing relay host
*/
getRelayHost(): string | undefined {
return this.relayClient?.getHost()
}
/**
* Configure auto-relay
*/
setAutoRelay(config: AutoRelayConfig): void {
if (!this.relayClient) {
this.logger.warn('Outgoing mail not configured - Auto relay mode ignored')
return
}
this.relayClient.setAutoRelay(config)
}
/**
* Check if auto-relay is enabled
*/
isAutoRelayEnabled(): boolean {
return this.relayClient?.isAutoRelayEnabled() ?? false
}
/**
* Get an email by ID
*/
async getEmail(id: string): Promise<Email> {
const email = await this.storage.getById(id)
if (!email) {
throw new Error('Email was not found')
}
// Sanitize HTML content
if (email.html) {
const sanitized = sanitizeHtml(email.html)
if (sanitized) {
email.html = sanitized
}
}
return email
}
/**
* Get an email's HTML content with embedded attachments resolved
*/
async getEmailHtml(id: string, options: HTMLRenderOptions = {}): Promise<string | undefined> {
const email = await this.getEmail(id)
if (!email.html) {
return undefined
}
let html = email.html
// Replace CID references with actual URLs
if (email.attachments && email.attachments.length > 0) {
html = replaceCidReferences(html, id, email.attachments, options.baseUrl)
}
return html
}
/**
* Get a readable stream of the raw .eml file
*/
async getRawEmail(id: string): Promise<Readable> {
const email = await this.getEmail(id)
return createReadStream(join(this.mailDir, `${email.id}.eml`))
}
/**
* Get all emails
*/
async getAllEmails(): Promise<Email[]> {
return this.storage.getAll()
}
/**
* Mark all emails as read
*/
async markAllRead(): Promise<number> {
return this.storage.markAllRead()
}
/**
* Delete an email by ID
*/
async deleteEmail(id: string): Promise<boolean> {
const email = await this.storage.getById(id)
if (!email) {
throw new Error('Email not found')
}
await this.removeEmailFiles(id)
this.logger.warn('Deleting email -', email.subject)
const result = await this.storage.delete(id)
if (result) {
this.emit('delete', { id })
}
return result
}
/**
* Delete all emails
*/
async deleteAllEmails(): Promise<number> {
this.logger.warn('Deleting all emails')
await this.clearMailDir()
const count = await this.storage.deleteAll()
this.emit('delete', { id: 'all' })
return count
}
/**
* Get an email attachment
*/
async getEmailAttachment(
id: string,
filename: string
): Promise<{ contentType: string; stream: Readable }> {
const email = await this.getEmail(id)
if (!email.attachments || email.attachments.length === 0) {
throw new Error('Email has no attachments')
}
const attachment = email.attachments.find(
(a) => a.generatedFileName === filename
)
if (!attachment) {
throw new Error('Attachment not found')
}
// Build the transformed attachment object for file path lookup
const transformedAttachment: TransformedAttachment = {
filename: attachment.filename,
generatedFileName: attachment.generatedFileName,
contentType: attachment.contentType,
contentDisposition: attachment.contentDisposition,
contentId: attachment.contentId,
size: attachment.size || 0,
transformed: true,
}
const filePath = getAttachmentFilePath(this.mailDir, id, transformedAttachment)
const stream = createReadStream(filePath)
return {
contentType: attachment.contentType,
stream,
}
}
/**
* Download an email as .eml file
*/
async downloadEmail(id: string): Promise<EmailDownload> {
await this.getEmail(id) // Verify email exists
return {
contentType: 'message/rfc822',
filename: `${id}.eml`,
stream: createReadStream(join(this.mailDir, `${id}.eml`)),
}
}
/**
* Relay an email to the configured outgoing server
*/
async relayEmail(idOrEmail: string | Email, isAutoRelay = false): Promise<void> {
if (!this.relayClient) {
throw new Error('Outgoing mail not configured')
}
const email = typeof idOrEmail === 'string'
? await this.getEmail(idOrEmail)
: idOrEmail
const rawStream = await this.getRawEmail(email.id)
const result = await this.relayClient.relay(email, rawStream, isAutoRelay)
if (!result.success) {
if (result.error) {
throw result.error
}
return
}
// Record the successful relay on the stored email so the API and UI can
// tell whether (and where) a message was delivered.
email.relayedAt = new Date()
email.relayedTo = result.recipients ?? []
await this.storage.save(email)
}
/**
* Load existing emails from the mail directory
*
* Only the newest `maxEmails` are restored — reading and parsing a directory
* that has accumulated tens of thousands of messages would otherwise stall
* startup and immediately blow past the store's limit anyway.
*/
async loadMailsFromDirectory(): Promise<void> {
let files: EmlFile[]
try {
files = await this.listEmlFiles()
} catch (err) {
this.logger.error('Error reading mail directory:', err)
return
}
const max = this.storage.options.maxEmails ?? 0
const selected = max > 0 ? files.slice(0, max) : files
if (selected.length < files.length) {
this.logger.warn(
`Restoring the ${selected.length} most recent of ${files.length} emails in ${this.mailDir}`
)
}
// Reading and parsing dominates the cost, so a chunk at a time runs in
// parallel; saving is cheap and happens in order afterwards. Working in
// chunks rather than parsing the whole directory up front keeps only one
// chunk of parsed emails in memory beyond the store itself.
//
// Oldest first, so arrival order — and therefore eviction order — matches
// the order the emails were originally received.
const ordered = [...selected].reverse()
for (let offset = 0; offset < ordered.length; offset += RESTORE_CHUNK_SIZE) {
const chunk = ordered.slice(offset, offset + RESTORE_CHUNK_SIZE)
const parsedChunk = await mapLimit(chunk, FS_CONCURRENCY, async (file) => {
try {
const data = await readFile(join(this.mailDir, `${file.id}.eml`), 'utf8')
this.logger.log('Restoring email:', file.id)
return { id: file.id, parsed: await parseEmailBuffer(data) }
} catch (err) {
this.logger.error(`Error restoring email ${file.id}:`, err)
return null
}
})
for (const entry of parsedChunk) {
if (!entry) {
continue
}
const { id, parsed } = entry
// Build envelope from parsed headers
const envelope: StoredEnvelope = {
from: parsed.from?.[0] ? { address: parsed.from[0].address } : false,
to: (parsed.to || []).map((addr) => ({ address: addr.address })),
host: 'restored',
remoteAddress: 'restored',
}
try {
// Restore any metadata persisted alongside the .eml.
const metadata = await this.storage.readMetadata?.(id)
await this.saveEmail(id, envelope, parsed, true, metadata)
} catch (err) {
this.logger.error(`Error restoring email ${id}:`, err)
}
}
}
}
/**
* Delete the oldest .eml files so at most `keep` remain on disk
*
* Files outliving the emails they belong to is what makes a long-lived
* MailDev slow to restart and eventually forces a manual wipe of the mail
* directory. Running this at startup keeps that from happening.
* @param keep - How many of the newest emails to retain (0 = keep everything)
* @returns Number of emails removed from disk
*/
async pruneMailDir(keep: number): Promise<number> {
if (keep <= 0) {
return 0
}
let files: EmlFile[]
try {
files = await this.listEmlFiles()
} catch (err) {
this.logger.error('Error reading mail directory:', err)
return 0
}
if (files.length <= keep) {
return 0
}
const stale = files.slice(keep)
await mapLimit(stale, FS_CONCURRENCY, (file) => this.removeEmailFiles(file.id))
this.logger.warn(
`Removed ${stale.length} old email(s) from ${this.mailDir} (keeping the newest ${keep})`
)
return stale.length
}
/**
* Build smtp-server configuration
*/
private buildServerConfig(): Record<string, unknown> {
const config: Record<string, unknown> = {
secure: this.options.secure || false,
onData: this.handleDataStream.bind(this),
logger: false,
hideSTARTTLS: true,
disabledCommands: ['AUTH'] as string[],
}
// Advertise and enforce a maximum message size. smtp-server rejects
// messages that declare an oversized SIZE at MAIL FROM, and flags the DATA
// stream with `sizeExceeded` when the body itself runs over (see
// processIncomingEmail). 0/undefined disables the limit.
if (this.options.maxMessageSize && this.options.maxMessageSize > 0) {
config.size = this.options.maxMessageSize
}
// Load TLS certificates
if (this.options.certPath) {
config.cert = readFileSync(this.options.certPath)
}
if (this.options.keyPath) {
config.key = readFileSync(this.options.keyPath)
}
// Configure authentication
if (this.options.auth) {
config.onAuth = createAuthCallback(this.options.auth)
config.disabledCommands = this.options.secure ? [] : ['STARTTLS']
}
// Hide extensions
if (this.options.hideExtensions) {
const hideOptions = this.getHideExtensionOptions(this.options.hideExtensions)
Object.assign(config, hideOptions)
}
return config
}
/**
* Get hide extension options for smtp-server
*/
private getHideExtensionOptions(
extensions: HideableExtension[]
): Record<string, boolean> {
const options: Record<string, boolean> = {}
for (const ext of extensions) {
const upperExt = ext.toUpperCase() as HideableExtension
if (!HIDEABLE_EXTENSIONS.includes(upperExt)) {
throw new Error(`Invalid hideable extension: ${ext}`)
}
options[`hide${upperExt}`] = true
}
return options
}
/**
* Handle incoming email data stream
*/
private handleDataStream(
stream: SMTPServerDataStream,
session: SMTPServerSession,
callback: (err: Error | null, message?: string) => void
): void {
// Process asynchronously but call callback synchronously as required by smtp-server
this.processIncomingEmail(stream, session)
.then((id) => callback(null, `Message queued as ${id}`))
.catch((err) => callback(err))
}
/**
* Process an incoming email
*/
private async processIncomingEmail(
stream: Readable,
session: SMTPServerSession
): Promise<string> {
const id = makeId()
const emlPath = join(this.mailDir, `${id}.eml`)
// Cap the bytes forwarded to the writer/parser at maxMessageSize. This
// bounds the work spent parsing a single message regardless of how much
// DATA the client actually sends, which is what stops a malicious multipart
// body with a huge number of parts from tying up the parser. The cap keeps
// draining the source after the limit (so the SMTP DATA command still
// completes) and records that the message was truncated so it can be
// rejected below. Well-behaved clients that declare an oversized SIZE are
// already refused earlier by smtp-server's `size` option.
const limit = this.options.maxMessageSize && this.options.maxMessageSize > 0
? this.options.maxMessageSize
: 0
const sizeState = { exceeded: false }
const source = limit ? stream.pipe(this.createSizeCap(limit, sizeState)) : stream
// Create two pass-through streams to duplicate the data
const emlPassThrough = new PassThrough()
const parsePassThrough = new PassThrough()
// Pipe to both streams
source.pipe(emlPassThrough)
source.pipe(parsePassThrough)
// Write to disk and parse in parallel
const [, parsed] = await Promise.all([
pipeline(emlPassThrough, createWriteStream(emlPath)),
parseEmailStream(parsePassThrough),
])
// Reject messages that ran past the size limit. The file only holds the
// truncated prefix, so discard it and refuse the message rather than storing
// a partial.
if (sizeState.exceeded) {
await rm(emlPath, { force: true })
const error = new Error('Message exceeds maximum allowed size') as Error & { responseCode?: number }
error.responseCode = 552 // permanent: exceeded storage allocation
throw error
}
// Save attachments
if (parsed.attachments && parsed.attachments.length > 0) {
await this.saveAttachments(id, parsed.attachments)
}
// Build envelope from session - convert smtp-server types to our types
const sessionMailFrom = session.envelope.mailFrom
const sessionRcptTo = session.envelope.rcptTo
const envelope: StoredEnvelope = {
from: sessionMailFrom
? { address: sessionMailFrom.address, args: sessionMailFrom.args as Record<string, string | boolean> | false }
: false,
to: (sessionRcptTo || []).map((addr) => ({
address: addr.address,
args: addr.args as Record<string, string | boolean> | false,
})),
host: session.hostNameAppearsAs,
remoteAddress: session.remoteAddress,
}
// Save email to storage
await this.saveEmail(id, envelope, parsed)
return id
}
/**
* Build a Transform that forwards at most `limit` bytes downstream and then
* keeps draining its input (so the source stream can finish) while dropping
* the excess. Sets `state.exceeded` once the limit is crossed.
*/
private createSizeCap(limit: number, state: { exceeded: boolean }): Transform {
let bytes = 0
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
if (state.exceeded) {
callback() // already over the limit: drain and drop
return
}
bytes += chunk.length
if (bytes > limit) {
state.exceeded = true
const allowed = chunk.length - (bytes - limit)
callback(null, allowed > 0 ? chunk.subarray(0, allowed) : undefined)
return
}
callback(null, chunk)
},
})
}
/**
* Save an email to storage
*/
private async saveEmail(
id: string,
envelope: StoredEnvelope,
parsed: ParsedEmail,
isRestored = false,
metadata?: EmailMetadata | null
): Promise<Email> {
const emlPath = join(this.mailDir, `${id}.eml`)
const emlStat = await stat(emlPath)
// Transform attachments for storage
const transformedAttachments: Attachment[] = parsed.attachments
? parsed.attachments.map((att) => {
const transformed = transformAttachment(att)
// Build attachment object, only including optional fields if they have values
const attachment: Attachment = {
filename: transformed.filename,
generatedFileName: transformed.generatedFileName,
contentType: transformed.contentType,
contentDisposition: transformed.contentDisposition,
}
if (transformed.contentId) {
attachment.contentId = transformed.contentId
}
if (transformed.size !== undefined) {
attachment.size = transformed.size
}
return attachment
})
: []
// Calculate BCC recipients - pass Address arrays as required
const envelopeToAddresses: string[] = (envelope.to || []).map((t) => {
if (typeof t === 'string') return t
return t.address
})
const toAddresses = parsed.to || []
const ccAddresses = parsed.cc || []
const calculatedBcc = calculateBcc(
envelopeToAddresses,
toAddresses,
ccAddresses
)
// Build email object, only including optional fields if they have values
const email: Email = {
id,
time: parsed.date || new Date(),
read: isRestored,
subject: parsed.subject || '',
source: emlPath,
size: emlStat.size,
sizeHuman: formatBytes(emlStat.size),
from: parsed.from || [],
to: parsed.to || [],
headers: this.headersToRecord(parsed.headers),
attachments: transformedAttachments,
envelope: {
from: this.toEnvelopeAddress(envelope.from),
to: envelope.to.map((t) => this.toEnvelopeAddress(t)),
},
calculatedBcc,
}
// Add optional fields only if they have values
if (parsed.cc && parsed.cc.length > 0) {
email.cc = parsed.cc
}
if (parsed.html) {
email.html = parsed.html
}
if (parsed.text) {
email.text = parsed.text
}
if (parsed.inReplyTo) {
email.inReplyTo = parsed.inReplyTo
}
if (parsed.priority) {
email.priority = parsed.priority
}
// Carry forward metadata persisted in a previous session (restore only).
if (metadata?.relayedAt) {
email.relayedAt = metadata.relayedAt
email.relayedTo = metadata.relayedTo ?? []
}
await this.storage.save(email)
this.logger.log('Saved email:', parsed.subject, 'id:', id)
// Auto-relay if enabled
if (!isRestored && this.isAutoRelayEnabled()) {
try {
await this.relayEmail(email, true)
} catch (err) {
this.logger.error('Error during auto-relay:', err)
}
}
if (!isRestored) {
this.emit('new', email)
}
return email
}
/**
* Convert envelope address to core format
*/
private toEnvelopeAddress(
addr: EnvelopeAddress | { address: string; args?: boolean | Record<string, unknown> } | false
): { address: string; args?: boolean | Record<string, unknown> } {
if (addr === false) {
return { address: '' }
}
// Only include args if it's truthy (not false or undefined)
const result: { address: string; args?: boolean | Record<string, unknown> } = {
address: addr.address,
}
if (addr.args) {
result.args = addr.args as boolean | Record<string, unknown>
}
return result
}
/**
* Save email attachments to disk
*/
private async saveAttachments(
emailId: string,
attachments: ParsedAttachment[]
): Promise<void> {
const attachDir = getAttachmentDir(this.mailDir, emailId)
if (!existsSync(attachDir)) {
mkdirSync(attachDir, { recursive: true })
}
for (const attachment of attachments) {
if (!attachment.content) {
continue
}
const transformed = transformAttachment(attachment)
const filePath = getAttachmentFilePath(this.mailDir, emailId, transformed)
await writeFile(filePath, attachment.content)
}
}
/**
* Convert headers Map to plain object
*/
private headersToRecord(
headers?: Map<string, string | string[]>
): Record<string, string | string[]> {
if (!headers) {
return {}
}
const result: Record<string, string | string[]> = {}
headers.forEach((value, key) => {
result[key] = value
})
return result
}
/**
* Handle SMTP server errors
*/
private handleServerError(err: NodeJS.ErrnoException): void {
// ECONNRESET is common when clients disconnect unexpectedly
if (err.code === 'ECONNRESET' && err.syscall === 'read') {
this.logger.warn(
`Ignoring "${err.message}" error - client likely disconnected`
)
this.logger.debug?.(err)
} else {
// Do not throw: this runs from an event callback, so a throw is uncaught
// and kills the process. A bare emit('error') with no listener does too.
this.logger.error(err.message)
if (this.listenerCount('error') > 0) {
this.emit('error', err)
}
}
}
/**
* Ensure the mail directory exists
*/
private ensureMailDir(): void {
if (!existsSync(this.mailDir)) {
mkdirSync(this.mailDir, { recursive: true })
}
this.logger.info('Using mail directory:', this.mailDir)
}
/**
* Clear all files in the mail directory
*/
private async clearMailDir(): Promise<void> {
try {
const files = await readdir(this.mailDir)
// Bounded concurrency: an inbox with tens of thousands of emails would
// otherwise try to unlink every file at once and hit EMFILE.
await mapLimit(files, FS_CONCURRENCY, async (file) => {
try {
await rm(join(this.mailDir, file), { recursive: true, force: true })
} catch (err) {
this.logger.error('Error deleting file:', file, err)
}
})
} catch (err) {
this.logger.error('Error clearing mail directory:', err)
}
}
/**
* List the .eml files in the mail directory, newest first
*/
private async listEmlFiles(): Promise<EmlFile[]> {
const entries = await readdir(this.mailDir, { withFileTypes: true })
const emlEntries = entries.filter(
(entry) => entry.isFile() && entry.name.endsWith('.eml')
)
const files = await mapLimit(emlEntries, FS_CONCURRENCY, async (entry) => {
try {
const info = await stat(join(this.mailDir, entry.name))
return { id: entry.name.slice(0, -4), mtimeMs: info.mtimeMs }
} catch {
// Raced with a delete; treat it as gone
return null
}
})
return files
.filter((file): file is EmlFile => file !== null)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
}
/**
* Remove an email's .eml file and attachment directory, if they exist
*/
private async removeEmailFiles(id: string): Promise<void> {
try {
await rm(join(this.mailDir, `${id}.eml`), { force: true })
} catch (err) {
this.logger.error('Error deleting email file:', err)
}
try {
await rm(getAttachmentDir(this.mailDir, id), { recursive: true, force: true })
} catch {
// Directory may not exist
}
}
}
/**
* Create an SMTP server instance
*/
export function createSMTPServer(options: SMTPServerOptions): SMTPServer {
return new SMTPServer(options)
}
+249
View File
@@ -0,0 +1,249 @@
/**
* @maildev/smtp - Type definitions
*/
import type { Storage, Address, Email, EnvelopeAddress as CoreEnvelopeAddress } from '@maildev/core'
// Re-export core types that we use
export type { Email, Address }
/**
* SMTP Server configuration options
*/
export interface SMTPServerOptions {
/** Port to listen on (default: 1025) */
port?: number
/** Host to bind to (default: '::') */
host?: string
/** Storage backend for emails */
storage: Storage
/** Directory for storing .eml files and attachments */
mailDir: string
/** Enable TLS/SSL */
secure?: boolean
/** Path to TLS certificate file */
certPath?: string
/** Path to TLS key file */
keyPath?: string
/** SMTP authentication credentials */
auth?: SMTPAuth
/** SMTP extensions to hide */
hideExtensions?: HideableExtension[]
/**
* Maximum accepted message size in bytes. Larger messages are rejected
* before being stored. Advertised via the SMTP SIZE extension. 0 or
* undefined disables the limit.
*/
maxMessageSize?: number
/** Auto-relay configuration */
autoRelay?: AutoRelayConfig
/** Logger instance or false to disable */
logger?: Logger | false
}
/**
* SMTP authentication credentials
*/
export interface SMTPAuth {
user: string
pass: string
}
/**
* SMTP extensions that can be hidden
*/
export type HideableExtension = 'STARTTLS' | 'PIPELINING' | '8BITMIME' | 'SMTPUTF8'
/**
* Auto-relay configuration
*/
export interface AutoRelayConfig {
/** Enable auto-relay */
enabled: boolean
/** Rules for filtering which emails to relay */
rules?: RelayRule[]
/** Override all recipients with this address */
defaultRecipient?: string
}
/**
* Relay rule for filtering emails
*/
export interface RelayRule {
/** Pattern to match (supports wildcards) */
allow?: string
/** Pattern to deny (supports wildcards) */
deny?: string
}
/**
* Outgoing relay server configuration
*/
export interface RelayConfig {
/** SMTP host */
host: string
/** SMTP port */
port: number
/** Enable TLS/SSL */
secure?: boolean
/** Authentication credentials */
auth?: SMTPAuth | undefined
}
/**
* SMTP session information from smtp-server
*/
export interface SMTPSession {
id: string
envelope: {
mailFrom: EnvelopeAddress | false
rcptTo: EnvelopeAddress[]
}
hostNameAppearsAs?: string
remoteAddress?: string
}
/**
* Envelope address from SMTP session
*/
export interface EnvelopeAddress {
address: string
args?: Record<string, string | boolean> | false
name?: string
}
/**
* Internal envelope structure for storing with email
*/
export interface StoredEnvelope {
from: EnvelopeAddress | CoreEnvelopeAddress | false
to: Array<EnvelopeAddress | CoreEnvelopeAddress>
host?: string | undefined
remoteAddress?: string | undefined
}
/**
* Parsed email structure from mailparser
*/
export interface ParsedEmail {
subject?: string | undefined
from?: Address[] | undefined
to?: Address[] | undefined
cc?: Address[] | undefined
bcc?: Address[] | undefined
date?: Date | undefined
html?: string | undefined
text?: string | undefined
headers?: Map<string, string | string[]> | undefined
messageId?: string | undefined
inReplyTo?: string | undefined
priority?: 'high' | 'normal' | 'low' | undefined
attachments?: ParsedAttachment[] | undefined
}
/**
* Attachment from mailparser
*/
export interface ParsedAttachment {
filename?: string | undefined
contentType: string
contentDisposition?: string | undefined
contentId?: string | undefined
size: number
content?: Buffer | undefined
checksum?: string | undefined
headers?: Map<string, string> | undefined
}
/**
* Transformed attachment for storage
*/
export interface TransformedAttachment {
filename: string
generatedFileName: string
contentType: string
contentDisposition: 'inline' | 'attachment'
contentId?: string | undefined
size: number
checksum?: string | undefined
transformed: true
}
/**
* Logger interface
*/
export interface Logger {
log: (...args: unknown[]) => void
info: (...args: unknown[]) => void
warn: (...args: unknown[]) => void
error: (...args: unknown[]) => void
debug?: (...args: unknown[]) => void
}
/**
* SMTP Server events
*/
export interface SMTPServerEvents {
/** Emitted when a new email is received */
new: (email: Email) => void
/** Emitted when an email is deleted */
delete: (info: { id: string; index?: number }) => void
/** Emitted when the server is closed */
close: () => void
/** Emitted on server errors */
error: (error: Error) => void
}
/**
* Relay task for queue processing
*/
export interface RelayTask {
email: Email
emailStream: NodeJS.ReadableStream
isAutoRelay: boolean
}
/**
* Relay result
*/
export interface RelayResult {
success: boolean
message?: string
/** Recipients the email was delivered to (present on a successful relay) */
recipients?: string[]
error?: Error
}
/**
* HTML rendering options for email
*/
export interface HTMLRenderOptions {
/** Base URL for attachment URLs */
baseUrl?: string
}
/**
* Email download response
*/
export interface EmailDownload {
contentType: string
filename: string
stream: NodeJS.ReadableStream
}
/**
* Address type for internal parsing
*/
export interface AddressValue {
address?: string
name?: string
}
/**
* Address list from mailparser
*/
export interface AddressList {
value: AddressValue[]
html?: string
text?: string
}