Upstream maildev/maildev state of the touched files before ec889a40ce
This commit is contained in:
commit
1ce2ff856b
5 files changed
+2325
No files matched your search
@@ -0,0 +1,872 @@
|
|||||||
|
/**
|
||||||
|
* @maildev/api - Fastify API Server
|
||||||
|
*
|
||||||
|
* REST API and WebSocket server for MailDev.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Fastify, {
|
||||||
|
type FastifyInstance,
|
||||||
|
type FastifyServerOptions,
|
||||||
|
type FastifyRequest,
|
||||||
|
type FastifyReply,
|
||||||
|
} from 'fastify'
|
||||||
|
import cors from '@fastify/cors'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { Server as SocketServer } from 'socket.io'
|
||||||
|
import { Server as MCPServer } from '@modelcontextprotocol/sdk/server/index.js'
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||||
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
|
||||||
|
import { registerMCPHandlers, type EmailDataSource } from '@maildev/mcp'
|
||||||
|
import { toSummary, type Storage, type Email, type SortOrder } from '@maildev/core'
|
||||||
|
import type { SMTPServer } from '@maildev/smtp'
|
||||||
|
import type {
|
||||||
|
APIServerOptions,
|
||||||
|
AuthConfig,
|
||||||
|
BulkDeleteEmailsRequest,
|
||||||
|
ConfigResponse,
|
||||||
|
SummaryQuery,
|
||||||
|
SummaryResponse,
|
||||||
|
} from './types.js'
|
||||||
|
import { VERSION } from './index.js'
|
||||||
|
|
||||||
|
const DEFAULT_PORT = 1080
|
||||||
|
const DEFAULT_HOST = '0.0.0.0'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on how many summaries a single request can ask for
|
||||||
|
*
|
||||||
|
* A client that asks for everything still gets a bounded response, so no single
|
||||||
|
* request can pin the event loop serializing the whole inbox.
|
||||||
|
*/
|
||||||
|
const MAX_PAGE_SIZE = 200
|
||||||
|
|
||||||
|
/** Page size used when a request doesn't ask for one */
|
||||||
|
const DEFAULT_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a query-string integer, ignoring anything that isn't a positive number
|
||||||
|
* @param value - Raw query parameter
|
||||||
|
* @returns The parsed value, or undefined if absent or unusable
|
||||||
|
*/
|
||||||
|
function parsePositiveInt(value: string | undefined): number | undefined {
|
||||||
|
if (value === undefined || value === '') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MailDev API Server
|
||||||
|
*
|
||||||
|
* Provides REST API and WebSocket endpoints for the MailDev UI.
|
||||||
|
*/
|
||||||
|
export class APIServer extends EventEmitter {
|
||||||
|
private app: FastifyInstance
|
||||||
|
private storage: Storage
|
||||||
|
private smtp: SMTPServer | undefined
|
||||||
|
private io: SocketServer | null = null
|
||||||
|
private options: APIServerOptions
|
||||||
|
private mcpTransports: Map<string, StreamableHTTPServerTransport> = new Map()
|
||||||
|
private pluginsRegistered = false
|
||||||
|
private boundAddress: { host: string; port: number } | null = null
|
||||||
|
|
||||||
|
constructor(options: APIServerOptions) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.storage = options.storage
|
||||||
|
this.smtp = options.smtp
|
||||||
|
this.options = options
|
||||||
|
|
||||||
|
// Create Fastify instance, serving over HTTPS when configured. Typed as a
|
||||||
|
// plain record because Fastify's overloaded call signature collapses
|
||||||
|
// `Parameters<typeof Fastify>[0]` to `never`.
|
||||||
|
const fastifyOptions: Record<string, unknown> = {
|
||||||
|
logger: options.logger ?? false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.https) {
|
||||||
|
if (!options.httpsCert || !options.httpsKey) {
|
||||||
|
throw new Error('HTTPS is enabled but httpsCert and httpsKey were not provided')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fastifyOptions.https = {
|
||||||
|
cert: readFileSync(options.httpsCert),
|
||||||
|
key: readFileSync(options.httpsKey),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
throw new Error(`Failed to read HTTPS certificate/key files: ${message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fastify's instance type differs for HTTPS; we hold it as the common
|
||||||
|
// FastifyInstance either way.
|
||||||
|
this.app = Fastify(fastifyOptions as unknown as FastifyServerOptions) as unknown as FastifyInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all plugins and routes (without starting the server)
|
||||||
|
*/
|
||||||
|
async registerPlugins(): Promise<void> {
|
||||||
|
if (this.pluginsRegistered) return
|
||||||
|
|
||||||
|
// Register CORS
|
||||||
|
await this.app.register(cors, {
|
||||||
|
origin: this.options.cors?.origin ?? true,
|
||||||
|
credentials: this.options.cors?.credentials ?? true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register authentication if configured
|
||||||
|
if (this.options.auth && this.options.auth.type === 'basic') {
|
||||||
|
this.registerAuthHook(this.options.auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
this.registerRoutes()
|
||||||
|
|
||||||
|
// Setup MCP server (if enabled)
|
||||||
|
this.setupMCP()
|
||||||
|
|
||||||
|
// Setup WebSocket (Socket.io)
|
||||||
|
this.setupWebSocket()
|
||||||
|
|
||||||
|
this.pluginsRegistered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening on the configured port
|
||||||
|
*/
|
||||||
|
async listen(): Promise<void> {
|
||||||
|
const port = this.options.port ?? DEFAULT_PORT
|
||||||
|
const host = this.options.host ?? DEFAULT_HOST
|
||||||
|
|
||||||
|
await this.app.listen({ port, host })
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const address = this.app.server.address()
|
||||||
|
const boundPort = address && typeof address === 'object' ? address.port : port
|
||||||
|
this.boundAddress = { host, port: boundPort }
|
||||||
|
|
||||||
|
const printHost = host === '0.0.0.0' ? 'localhost' : host
|
||||||
|
const protocol = this.options.https ? 'https' : 'http'
|
||||||
|
console.info(`MailDev API running at ${protocol}://${printHost}:${boundPort}${this.options.basePath ?? ''}`)
|
||||||
|
|
||||||
|
this.emit('listening', { port: boundPort, host })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the address the API server is bound to, or null before it listens
|
||||||
|
*
|
||||||
|
* After listen(), 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 } | null {
|
||||||
|
return this.boundAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the port the API server is bound to, or null before it listens
|
||||||
|
*
|
||||||
|
* After listen() with `port: 0`, this is the OS-assigned ephemeral port.
|
||||||
|
*/
|
||||||
|
getPort(): number | null {
|
||||||
|
return this.boundAddress?.port ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the API server
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
await this.registerPlugins()
|
||||||
|
await this.listen()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the API server
|
||||||
|
*/
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.emit('close')
|
||||||
|
|
||||||
|
// Close any open MCP sessions (each closes its paired server too).
|
||||||
|
for (const transport of this.mcpTransports.values()) {
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
this.mcpTransports.clear()
|
||||||
|
|
||||||
|
if (this.io) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.io!.close(() => resolve())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.app.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the underlying Fastify instance (for testing)
|
||||||
|
*/
|
||||||
|
get server(): FastifyInstance {
|
||||||
|
return this.app
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register HTTP Basic Auth hook
|
||||||
|
*/
|
||||||
|
private registerAuthHook(auth: AuthConfig): void {
|
||||||
|
const basePath = this.options.basePath ?? ''
|
||||||
|
const healthPath = `${basePath}/api/healthz`
|
||||||
|
|
||||||
|
this.app.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Skip auth for health check (always available, even without auth)
|
||||||
|
if (request.url.split('?')[0] === healthPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = request.headers.authorization
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
reply.header('WWW-Authenticate', 'Basic realm="MailDev"')
|
||||||
|
return reply.status(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, credentials] = authorization.split(' ')
|
||||||
|
|
||||||
|
if (type !== 'Basic' || !credentials) {
|
||||||
|
reply.header('WWW-Authenticate', 'Basic realm="MailDev"')
|
||||||
|
return reply.status(401).send({ error: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = Buffer.from(credentials, 'base64').toString()
|
||||||
|
const [username, password] = decoded.split(':')
|
||||||
|
|
||||||
|
if (username !== auth.user || password !== auth.pass) {
|
||||||
|
reply.header('WWW-Authenticate', 'Basic realm="MailDev"')
|
||||||
|
return reply.status(401).send({ error: 'Invalid username or password' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all routes
|
||||||
|
*/
|
||||||
|
private registerRoutes(): void {
|
||||||
|
const basePath = this.options.basePath ?? ''
|
||||||
|
const apiPath = `${basePath}/api`
|
||||||
|
|
||||||
|
// Health check (always available, even without auth)
|
||||||
|
this.app.get(`${apiPath}/healthz`, async () => true)
|
||||||
|
|
||||||
|
// Config endpoint
|
||||||
|
this.app.get(`${apiPath}/config`, async (): Promise<ConfigResponse> => {
|
||||||
|
return {
|
||||||
|
version: VERSION,
|
||||||
|
smtpPort: this.smtp ? 1025 : undefined, // TODO: Get actual port from SMTP server
|
||||||
|
isOutgoingEnabled: this.smtp?.isRelayEnabled() ?? false,
|
||||||
|
outgoingHost: this.smtp?.getRelayHost() ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Paginated summaries — what the web UI lists.
|
||||||
|
//
|
||||||
|
// Registered before /email/:id so the literal path wins, same as
|
||||||
|
// /email/all and /email/read-all below.
|
||||||
|
this.app.get(`${apiPath}/email/summary`, async (request): Promise<SummaryResponse> => {
|
||||||
|
const query = request.query as SummaryQuery
|
||||||
|
const skip = parsePositiveInt(query.skip) ?? 0
|
||||||
|
const requested = parsePositiveInt(query.limit)
|
||||||
|
// A missing or zero limit means "a page", not "everything" — storage
|
||||||
|
// treats limit 0 as unbounded, which would defeat MAX_PAGE_SIZE.
|
||||||
|
const limit =
|
||||||
|
requested === undefined || requested === 0
|
||||||
|
? DEFAULT_PAGE_SIZE
|
||||||
|
: Math.min(requested, MAX_PAGE_SIZE)
|
||||||
|
const sort: SortOrder = query.sort === 'asc' ? 'asc' : 'desc'
|
||||||
|
|
||||||
|
// Storage returns full emails; the body-free summary projection happens
|
||||||
|
// here, at the API boundary, so a listing stays a few hundred bytes per
|
||||||
|
// email rather than tens of kilobytes.
|
||||||
|
const result = await this.storage.list({
|
||||||
|
skip,
|
||||||
|
limit,
|
||||||
|
sort,
|
||||||
|
...(query.search ? { search: query.search } : {}),
|
||||||
|
...(query.unread === 'true' ? { unreadOnly: true } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: result.items.map(toSummary),
|
||||||
|
total: result.total,
|
||||||
|
storeTotal: result.storeTotal,
|
||||||
|
unread: result.unread,
|
||||||
|
skip: result.skip,
|
||||||
|
limit: result.limit,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all emails
|
||||||
|
this.app.get(`${apiPath}/email`, async (request: FastifyRequest) => {
|
||||||
|
const query = request.query as Record<string, string>
|
||||||
|
const { skip, limit, sort, ...filterQuery } = query
|
||||||
|
const skipCount = parsePositiveInt(skip) ?? 0
|
||||||
|
const limitCount = parsePositiveInt(limit)
|
||||||
|
|
||||||
|
const emails = await this.storage.getAll()
|
||||||
|
|
||||||
|
// Apply filtering if query params provided
|
||||||
|
let result = emails
|
||||||
|
if (Object.keys(filterQuery).length > 0) {
|
||||||
|
result = this.filterEmails(emails, filterQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order by received time only when asked. Without `sort` the emails come
|
||||||
|
// back in arrival order, which is what this endpoint has always done.
|
||||||
|
if (sort === 'desc' || sort === 'asc') {
|
||||||
|
const direction = sort === 'asc' ? 1 : -1
|
||||||
|
result = result
|
||||||
|
.map((email) => ({ email, time: new Date(email.time).getTime() }))
|
||||||
|
.sort((a, b) => direction * (a.time - b.time))
|
||||||
|
.map((entry) => entry.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination. No implicit limit here: this endpoint predates
|
||||||
|
// /email/summary and callers rely on getting the whole inbox back.
|
||||||
|
return limitCount === undefined
|
||||||
|
? result.slice(skipCount)
|
||||||
|
: result.slice(skipCount, skipCount + limitCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get single email
|
||||||
|
this.app.get<{ Params: { id: string } }>(
|
||||||
|
`${apiPath}/email/:id`,
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
|
||||||
|
const email = await this.storage.getById(id)
|
||||||
|
if (!email) {
|
||||||
|
return reply.status(404).send({ error: 'Email was not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
if (!email.read) {
|
||||||
|
email.read = true
|
||||||
|
await this.storage.save(email)
|
||||||
|
// Let other tabs update their list and unread badge; the tab that
|
||||||
|
// opened the email has already done so optimistically.
|
||||||
|
this.io?.emit('readMail', { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete single email
|
||||||
|
this.app.delete<{ Params: { id: string } }>(`${apiPath}/email/:id`, async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deleted = await this.deleteEmail(id)
|
||||||
|
if (!deleted) {
|
||||||
|
return reply.status(404).send({ error: 'Email was not found' })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete multiple emails
|
||||||
|
this.app.post<{ Body: BulkDeleteEmailsRequest }>(
|
||||||
|
`${apiPath}/email/delete`,
|
||||||
|
async (request, reply) => {
|
||||||
|
const ids = request.body?.ids
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Array.isArray(ids) ||
|
||||||
|
ids.some((id) => typeof id !== 'string' || id.trim().length === 0)
|
||||||
|
) {
|
||||||
|
return reply
|
||||||
|
.status(400)
|
||||||
|
.send({ error: 'Request body must include an ids array of email IDs' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueIds = Array.from(new Set(ids))
|
||||||
|
const deleted: string[] = []
|
||||||
|
const notFound: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const id of uniqueIds) {
|
||||||
|
const wasDeleted = await this.deleteEmail(id)
|
||||||
|
if (wasDeleted) {
|
||||||
|
deleted.push(id)
|
||||||
|
} else {
|
||||||
|
notFound.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted, notFound }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete all emails
|
||||||
|
this.app.delete(`${apiPath}/email/all`, async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
if (this.smtp) {
|
||||||
|
await this.smtp.deleteAllEmails()
|
||||||
|
} else {
|
||||||
|
await this.storage.deleteAll()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark all emails as read
|
||||||
|
this.app.patch(`${apiPath}/email/read-all`, async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
const count = this.smtp
|
||||||
|
? await this.smtp.markAllRead()
|
||||||
|
: await this.storage.markAllRead()
|
||||||
|
// Only tabs other than the one that triggered it need telling, and only
|
||||||
|
// when something actually changed.
|
||||||
|
if (count > 0) {
|
||||||
|
this.io?.emit('readAllMail')
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get email HTML with embedded attachments
|
||||||
|
this.app.get<{ Params: { id: string } }>(
|
||||||
|
`${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 })
|
||||||
|
if (!html) {
|
||||||
|
return reply.status(404).send({ error: 'Email has no HTML content' })
|
||||||
|
}
|
||||||
|
reply.type('text/html')
|
||||||
|
return html
|
||||||
|
} else {
|
||||||
|
// Fallback for storage-only mode
|
||||||
|
const email = await this.storage.getById(id)
|
||||||
|
if (!email) {
|
||||||
|
return reply.status(404).send({ error: 'Email was not found' })
|
||||||
|
}
|
||||||
|
if (!email.html) {
|
||||||
|
return reply.status(404).send({ error: 'Email has no HTML content' })
|
||||||
|
}
|
||||||
|
reply.type('text/html')
|
||||||
|
return email.html
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(404).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get raw email source
|
||||||
|
this.app.get<{ Params: { id: string } }>(
|
||||||
|
`${apiPath}/email/:id/source`,
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.smtp) {
|
||||||
|
const stream = await this.smtp.getRawEmail(id)
|
||||||
|
return reply.send(stream)
|
||||||
|
} else {
|
||||||
|
return reply.status(404).send({ error: 'Email source not available' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(404).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Download email as .eml
|
||||||
|
this.app.get<{ Params: { id: string } }>(
|
||||||
|
`${apiPath}/email/:id/download`,
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.smtp) {
|
||||||
|
const download = await this.smtp.downloadEmail(id)
|
||||||
|
reply.header('Content-Disposition', `attachment; filename=${download.filename}`)
|
||||||
|
reply.type(download.contentType)
|
||||||
|
return reply.send(download.stream)
|
||||||
|
} else {
|
||||||
|
return reply.status(404).send({ error: 'Email download not available' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(404).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get attachment
|
||||||
|
this.app.get<{ Params: { id: string; filename: string } }>(
|
||||||
|
`${apiPath}/email/:id/attachment/:filename`,
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id, filename } = request.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.smtp) {
|
||||||
|
const { contentType, stream } = await this.smtp.getEmailAttachment(id, filename)
|
||||||
|
reply.type(contentType)
|
||||||
|
return reply.send(stream)
|
||||||
|
} else {
|
||||||
|
return reply.status(404).send({ error: 'Attachment not available' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(404).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Relay email
|
||||||
|
this.app.post<{ Params: { id: string; relayTo?: string } }>(
|
||||||
|
`${apiPath}/email/:id/relay/:relayTo?`,
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id, relayTo } = request.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.smtp) {
|
||||||
|
return reply.status(500).send({ error: 'SMTP server not configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.smtp.isRelayEnabled()) {
|
||||||
|
return reply.status(500).send({ error: 'Outgoing mail not configured' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the email
|
||||||
|
const email = await this.smtp.getEmail(id)
|
||||||
|
|
||||||
|
// Override recipient if specified
|
||||||
|
if (relayTo) {
|
||||||
|
const emailRegexp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||||
|
if (!emailRegexp.test(relayTo)) {
|
||||||
|
return reply.status(400).send({ error: `Incorrect email address provided: ${relayTo}` })
|
||||||
|
}
|
||||||
|
email.to = [{ address: relayTo }]
|
||||||
|
email.envelope.to = [{ address: relayTo, args: false }]
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.smtp.relayEmail(email)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reload emails from directory
|
||||||
|
this.app.get(`${apiPath}/reloadMailsFromDirectory`, async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
if (this.smtp) {
|
||||||
|
await this.smtp.loadMailsFromDirectory()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete one email using SMTP when available so delete events are emitted.
|
||||||
|
*/
|
||||||
|
private async deleteEmail(id: string): Promise<boolean> {
|
||||||
|
const email = await this.storage.getById(id)
|
||||||
|
if (!email) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.smtp) {
|
||||||
|
await this.smtp.deleteEmail(id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.storage.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup WebSocket (Socket.io) for real-time updates
|
||||||
|
*/
|
||||||
|
private setupWebSocket(): void {
|
||||||
|
if (!this.smtp) {
|
||||||
|
return // WebSocket requires SMTP server for events
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = this.options.basePath ?? ''
|
||||||
|
|
||||||
|
// Create Socket.io server attached to the underlying HTTP server
|
||||||
|
this.io = new SocketServer(this.app.server, {
|
||||||
|
path: `${basePath}/socket.io`,
|
||||||
|
cors: {
|
||||||
|
origin: this.options.cors?.origin ?? '*',
|
||||||
|
credentials: this.options.cors?.credentials ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe once and broadcast, rather than adding a pair of SMTP listeners
|
||||||
|
// per connected socket — that grew with every open tab and tripped the
|
||||||
|
// EventEmitter max-listeners warning.
|
||||||
|
//
|
||||||
|
// Only the summary goes over the wire: clients use the event to refresh
|
||||||
|
// their list and show a notification, and shipping full bodies to every
|
||||||
|
// tab on every delivery is what made a burst of mail so expensive.
|
||||||
|
this.smtp.on('new', (email: Email) => {
|
||||||
|
this.io?.emit('newMail', toSummary(email))
|
||||||
|
})
|
||||||
|
|
||||||
|
this.smtp.on('delete', (data: { id: string; index?: number }) => {
|
||||||
|
this.io?.emit('deleteMail', data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup MCP (Model Context Protocol) server for Claude integration
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Best-effort public base URL of the web UI (no trailing slash), derived
|
||||||
|
* from this server's own listen options. Used for MCP email deep links when
|
||||||
|
* no explicit URL is configured.
|
||||||
|
*/
|
||||||
|
private defaultWebUrl(): string {
|
||||||
|
const host = this.options.host ?? DEFAULT_HOST
|
||||||
|
const printHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host
|
||||||
|
const port = this.options.port ?? DEFAULT_PORT
|
||||||
|
const protocol = this.options.https ? 'https' : 'http'
|
||||||
|
const basePath = this.options.basePath ?? ''
|
||||||
|
return `${protocol}://${printHost}:${port}${basePath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupMCP(): void {
|
||||||
|
if (!this.options.mcp?.enabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = this.options.basePath ?? ''
|
||||||
|
|
||||||
|
// Create a data source adapter that uses direct storage access
|
||||||
|
const dataSource: EmailDataSource = {
|
||||||
|
getEmails: () => this.storage.getAll(),
|
||||||
|
getEmail: async (id: string) => {
|
||||||
|
const email = await this.storage.getById(id)
|
||||||
|
if (!email) {
|
||||||
|
throw new Error(`Email not found: ${id}`)
|
||||||
|
}
|
||||||
|
return email
|
||||||
|
},
|
||||||
|
deleteEmail: async (id: string) => {
|
||||||
|
if (this.smtp) {
|
||||||
|
await this.smtp.deleteEmail(id)
|
||||||
|
} else {
|
||||||
|
const deleted = await this.storage.delete(id)
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error(`Email not found: ${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getAttachment: async (emailId: string, filename: string) => {
|
||||||
|
if (this.smtp) {
|
||||||
|
const { stream } = await this.smtp.getEmailAttachment(emailId, filename)
|
||||||
|
// Convert stream to buffer
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.from(chunk))
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks)
|
||||||
|
}
|
||||||
|
throw new Error('Attachments require SMTP server')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep links point at this server's own web UI unless an explicit public
|
||||||
|
// URL is configured (e.g. when reached through a reverse proxy).
|
||||||
|
const webUrl = this.options.mcp?.webUrl ?? this.defaultWebUrl()
|
||||||
|
|
||||||
|
// Build a fresh MCP server for each session. A single `Server` instance can
|
||||||
|
// only be bound to one transport at a time, so sharing one across sessions
|
||||||
|
// makes the second `connect()` throw "Already connected to a transport".
|
||||||
|
const createMcpServer = (): MCPServer => {
|
||||||
|
const server = new MCPServer(
|
||||||
|
{ name: 'maildev', version: VERSION },
|
||||||
|
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
||||||
|
)
|
||||||
|
registerMCPHandlers(server, dataSource, { webUrl })
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /mcp - Handle JSON-RPC requests
|
||||||
|
this.app.post(`${basePath}/mcp`, async (request, reply) => {
|
||||||
|
const sessionId = request.headers['mcp-session-id'] as string | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
let transport = sessionId ? this.mcpTransports.get(sessionId) : undefined
|
||||||
|
|
||||||
|
if (!transport) {
|
||||||
|
// Only spin up a new session (and its own server) for an initialize
|
||||||
|
// request. Any other request without a live session is a protocol
|
||||||
|
// error — respond with a JSON-RPC error rather than silently opening
|
||||||
|
// an orphaned transport.
|
||||||
|
if (sessionId || !isInitializeRequest(request.body)) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: 'Bad Request: No valid session ID provided',
|
||||||
|
},
|
||||||
|
id: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => randomUUID(),
|
||||||
|
onsessioninitialized: (id) => {
|
||||||
|
this.mcpTransports.set(id, transport!)
|
||||||
|
},
|
||||||
|
onsessionclosed: (id) => {
|
||||||
|
this.mcpTransports.delete(id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await createMcpServer().connect(transport as unknown as Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
await transport.handleRequest(request.raw, reply.raw, request.body)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /mcp - SSE stream for server notifications
|
||||||
|
this.app.get(`${basePath}/mcp`, async (request, reply) => {
|
||||||
|
const sessionId = request.headers['mcp-session-id'] as string
|
||||||
|
|
||||||
|
if (!sessionId || !this.mcpTransports.has(sessionId)) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid or missing session ID' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = this.mcpTransports.get(sessionId)!
|
||||||
|
await transport.handleRequest(request.raw, reply.raw)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /mcp - Terminate session
|
||||||
|
this.app.delete(`${basePath}/mcp`, async (request, reply) => {
|
||||||
|
const sessionId = request.headers['mcp-session-id'] as string
|
||||||
|
|
||||||
|
if (!sessionId || !this.mcpTransports.has(sessionId)) {
|
||||||
|
return reply.status(400).send({ error: 'Invalid or missing session ID' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = this.mcpTransports.get(sessionId)!
|
||||||
|
await transport.handleRequest(request.raw, reply.raw)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return reply.status(500).send({ error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.info(`MCP server enabled at ${basePath}/mcp`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter emails by query parameters (supports dot notation)
|
||||||
|
*/
|
||||||
|
private filterEmails(emails: Email[], query: Record<string, string>): Email[] {
|
||||||
|
return emails.filter((email) => {
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
const emailValue = this.getNestedValue(email as unknown as Record<string, unknown>, key)
|
||||||
|
|
||||||
|
if (emailValue === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle array values (e.g., from.address where from is an array)
|
||||||
|
if (Array.isArray(emailValue)) {
|
||||||
|
if (!emailValue.some((v) => String(v) === value)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if (String(emailValue) !== value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nested value from object using dot notation
|
||||||
|
*/
|
||||||
|
private getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||||
|
const parts = path.split('.')
|
||||||
|
let current: unknown = obj
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
// If current is an array, map to get the property from each item
|
||||||
|
return current.map((item) => {
|
||||||
|
if (typeof item === 'object' && item !== null) {
|
||||||
|
return (item as Record<string, unknown>)[part]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current === 'object') {
|
||||||
|
current = (current as Record<string, unknown>)[part]
|
||||||
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an API server instance
|
||||||
|
*/
|
||||||
|
export function createAPIServer(options: APIServerOptions): APIServer {
|
||||||
|
return new APIServer(options)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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, '\\$&')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user