Files
user-system/frontend/admin/scripts/mock-smtp-capture.mjs

186 lines
4.4 KiB
JavaScript

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 <CR><LF>.<CR><LF>')
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))
})