/** * @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 = 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[0]` to `never`. const fastifyOptions: Record = { 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 { 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 { 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 { await this.registerPlugins() await this.listen() } /** * Stop the API server */ async stop(): Promise { 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((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 => { 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 => { 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 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 try { if (this.smtp) { // No host: the HTML is rendered for a browser already on this origin const html = await this.smtp.getEmailHtml(id, { basePath }) if (!html) { return reply.status(404).send({ error: 'Email has no HTML content' }) } 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 { 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): Email[] { return emails.filter((email) => { for (const [key, value] of Object.entries(query)) { const emailValue = this.getNestedValue(email as unknown as Record, 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, 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)[part] } return undefined }) } if (typeof current === 'object') { current = (current as Record)[part] } else { return undefined } } return current } } /** * Create an API server instance */ export function createAPIServer(options: APIServerOptions): APIServer { return new APIServer(options) }