186 lines
4.4 KiB
JavaScript
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))
|
|
})
|