import process from 'node:process' import path from 'node:path' import net from 'node:net' import { appendFile, mkdir, writeFile } from 'node:fs/promises' function parseArgs(argv) { const args = new Map() for (let index = 0; index < argv.length; index += 1) { const value = argv[index] if (!value.startsWith('--')) { continue } const key = value.slice(2) const nextValue = argv[index + 1] if (nextValue && !nextValue.startsWith('--')) { args.set(key, nextValue) index += 1 continue } args.set(key, 'true') } return args } const args = parseArgs(process.argv.slice(2)) const port = Number(args.get('port') ?? process.env.SMTP_CAPTURE_PORT ?? 2525) const outputPath = path.resolve(args.get('output') ?? process.env.SMTP_CAPTURE_OUTPUT ?? './smtp-capture.jsonl') if (!Number.isInteger(port) || port <= 0) { throw new Error(`Invalid SMTP capture port: ${port}`) } await mkdir(path.dirname(outputPath), { recursive: true }) await writeFile(outputPath, '', 'utf8') let writeQueue = Promise.resolve() function queueMessageWrite(message) { writeQueue = writeQueue.then(() => appendFile(outputPath, `${JSON.stringify(message)}\n`, 'utf8')) return writeQueue } function createSessionState() { return { buffer: '', dataMode: false, mailFrom: '', rcptTo: [], data: '', } } const server = net.createServer((socket) => { socket.setEncoding('utf8') let session = createSessionState() const reply = (line) => { socket.write(`${line}\r\n`) } const resetMessageState = () => { session.dataMode = false session.mailFrom = '' session.rcptTo = [] session.data = '' } const flushBuffer = async () => { while (true) { if (session.dataMode) { const messageTerminatorIndex = session.buffer.indexOf('\r\n.\r\n') if (messageTerminatorIndex === -1) { session.data += session.buffer session.buffer = '' return } session.data += session.buffer.slice(0, messageTerminatorIndex) session.buffer = session.buffer.slice(messageTerminatorIndex + 5) const capturedMessage = { timestamp: new Date().toISOString(), mailFrom: session.mailFrom, rcptTo: session.rcptTo, data: session.data.replace(/\r\n\.\./g, '\r\n.'), } await queueMessageWrite(capturedMessage) resetMessageState() reply('250 OK') continue } const lineEndIndex = session.buffer.indexOf('\r\n') if (lineEndIndex === -1) { return } const line = session.buffer.slice(0, lineEndIndex) session.buffer = session.buffer.slice(lineEndIndex + 2) const normalized = line.toUpperCase() if (normalized.startsWith('EHLO')) { socket.write('250-localhost\r\n250 OK\r\n') continue } if (normalized.startsWith('HELO')) { reply('250 OK') continue } if (normalized.startsWith('MAIL FROM:')) { resetMessageState() session.mailFrom = line.slice('MAIL FROM:'.length).trim() reply('250 OK') continue } if (normalized.startsWith('RCPT TO:')) { session.rcptTo.push(line.slice('RCPT TO:'.length).trim()) reply('250 OK') continue } if (normalized === 'DATA') { session.dataMode = true session.data = '' reply('354 End data with .') continue } if (normalized === 'RSET') { resetMessageState() reply('250 OK') continue } if (normalized === 'NOOP') { reply('250 OK') continue } if (normalized === 'QUIT') { reply('221 Bye') socket.end() return } reply('250 OK') } } socket.on('data', (chunk) => { session.buffer += chunk void flushBuffer().catch((error) => { console.error(error?.stack ?? String(error)) socket.destroy(error) }) }) socket.on('error', () => {}) reply('220 localhost ESMTP ready') }) server.listen(port, '127.0.0.1', () => { console.log(`SMTP capture listening on 127.0.0.1:${port}`) }) async function shutdown() { server.close() await writeQueue.catch(() => {}) } process.on('SIGINT', () => { void shutdown().finally(() => process.exit(0)) }) process.on('SIGTERM', () => { void shutdown().finally(() => process.exit(0)) })