126 lines
6.4 KiB
JavaScript
126 lines
6.4 KiB
JavaScript
// Test-only mail capture. Never imported by the application.
|
|
import { createServer as createSMTPServer } from "node:net";
|
|
import { createServer as createHTTPServer } from "node:http";
|
|
import { resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const maxMailBytes = 128 * 1024;
|
|
|
|
export async function startLocalSMTPFixture({ smtpPort = 0, controlPort = 0 } = {}) {
|
|
const messages = [];
|
|
const sockets = new Set();
|
|
const smtp = createSMTPServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.on("error", () => socket.destroy());
|
|
socket.setEncoding("latin1");
|
|
socket.write("220 localhost test capture ESMTP\r\n");
|
|
let buffer = "";
|
|
let sender = "";
|
|
let recipients = [];
|
|
let data = null;
|
|
let dataBytes = 0;
|
|
const reset = () => { sender = ""; recipients = []; data = null; dataBytes = 0; };
|
|
socket.on("data", (chunk) => {
|
|
buffer += chunk;
|
|
if (buffer.length + dataBytes > maxMailBytes) {
|
|
reset();
|
|
socket.end("552 Fixture message size exceeded\r\n");
|
|
return;
|
|
}
|
|
while (buffer.includes("\n")) {
|
|
const end = buffer.indexOf("\n");
|
|
const line = buffer.slice(0, end).replace(/\r$/, "");
|
|
buffer = buffer.slice(end + 1);
|
|
if (data !== null) {
|
|
if (line === ".") {
|
|
try {
|
|
if (messages.length >= 100) throw new Error("Fixture mail capacity exceeded.");
|
|
messages.push({ sequence: messages.length + 1, recipients: [...recipients], body: decodePlainMessage(data.join("\r\n") + "\r\n") });
|
|
socket.write("250 Captured locally\r\n");
|
|
} catch {
|
|
socket.write("554 Unsupported fixture mail\r\n");
|
|
}
|
|
reset();
|
|
} else {
|
|
dataBytes += line.length + 2;
|
|
data.push(line.startsWith("..") ? line.slice(1) : line);
|
|
}
|
|
continue;
|
|
}
|
|
if (/^(EHLO|HELO)(\s|$)/i.test(line)) socket.write(`250-localhost\r\n250 SIZE ${maxMailBytes}\r\n`);
|
|
else if (/^MAIL FROM:/i.test(line)) {
|
|
reset();
|
|
sender = /^MAIL FROM:\s*<([^>]+)>/i.exec(line)?.[1] ?? "";
|
|
socket.write(sender ? "250 Sender accepted\r\n" : "501 Invalid sender\r\n");
|
|
} else if (/^RCPT TO:/i.test(line)) {
|
|
const recipient = /^RCPT TO:\s*<([^>]+)>/i.exec(line)?.[1]?.toLowerCase() ?? "";
|
|
if (!sender) socket.write("503 Sender required\r\n");
|
|
else if (!/^[^@\s]+@[^@\s]+\.test$/.test(recipient)) socket.write("550 Only synthetic .test recipients are accepted\r\n");
|
|
else { recipients.push(recipient); socket.write("250 Recipient accepted\r\n"); }
|
|
} else if (/^DATA$/i.test(line)) {
|
|
if (!sender || !recipients.length) socket.write("503 Envelope required\r\n");
|
|
else { data = []; dataBytes = 0; socket.write("354 End with dot on its own line\r\n"); }
|
|
} else if (/^RSET$/i.test(line)) { reset(); socket.write("250 Reset\r\n"); }
|
|
else if (/^NOOP$/i.test(line)) socket.write("250 OK\r\n");
|
|
else if (/^QUIT$/i.test(line)) { socket.end("221 Closing\r\n"); break; }
|
|
else socket.write("502 Command unavailable in local fixture\r\n");
|
|
}
|
|
});
|
|
});
|
|
const control = createHTTPServer((request, response) => {
|
|
response.setHeader("Cache-Control", "no-store");
|
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
if (request.method !== "GET" || request.headers.origin) { response.writeHead(403).end("{}"); return; }
|
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
if (url.pathname === "/health") { response.end('{"status":"local-fixture"}'); return; }
|
|
const recipient = (url.searchParams.get("recipient") ?? "").toLowerCase();
|
|
if (url.pathname !== "/messages" || !/^[^@\s]+@[^@\s]+\.test$/.test(recipient)) { response.writeHead(400).end("{}"); return; }
|
|
response.end(JSON.stringify(messages.filter((message) => message.recipients.includes(recipient)).map(({ sequence, body }) => ({ sequence, body }))));
|
|
});
|
|
const listen = (server, port) => new Promise((accept, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(port, "127.0.0.1", () => { server.off("error", reject); accept(); });
|
|
});
|
|
await listen(smtp, smtpPort);
|
|
try { await listen(control, controlPort); }
|
|
catch (error) { smtp.close(); throw error; }
|
|
return {
|
|
smtpPort: smtp.address().port,
|
|
controlPort: control.address().port,
|
|
close: async () => {
|
|
sockets.forEach((socket) => socket.destroy());
|
|
control.closeAllConnections();
|
|
await Promise.all([smtp, control].map((server) => new Promise((accept) => server.close(accept))));
|
|
},
|
|
};
|
|
}
|
|
|
|
export function decodePlainMessage(raw) {
|
|
const separator = /\r?\n\r?\n/.exec(raw);
|
|
if (!separator) throw new Error("Mail has no MIME header boundary.");
|
|
const headers = raw.slice(0, separator.index).replace(/\r?\n[ \t]+/g, " ");
|
|
const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headers)?.[1] ?? "text/plain";
|
|
if (!/^text\/plain(?:;|$)/i.test(contentType)) throw new Error("Only text/plain fixture mail is supported.");
|
|
const charset = /charset=["']?([^;\s"']+)/i.exec(contentType)?.[1] ?? "utf-8";
|
|
if (!/^(utf-8|us-ascii)$/i.test(charset)) throw new Error("Unsupported fixture mail charset.");
|
|
const encoding = /^content-transfer-encoding:\s*([^\r\n]+)/im.exec(headers)?.[1].trim().toLowerCase() ?? "7bit";
|
|
let body = raw.slice(separator.index + separator[0].length);
|
|
if (encoding === "base64") return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
|
if (encoding === "quoted-printable") {
|
|
body = body.replace(/=\r?\n/g, "").replace(/=([\da-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
} else if (encoding !== "7bit" && encoding !== "8bit") {
|
|
throw new Error("Unsupported fixture mail transfer encoding.");
|
|
}
|
|
return Buffer.from(body, "latin1").toString("utf8");
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
const fixture = await startLocalSMTPFixture({
|
|
smtpPort: Number.parseInt(process.env.AUTH_RECOVERY_SMTP_PORT ?? "2527", 10),
|
|
controlPort: Number.parseInt(process.env.AUTH_RECOVERY_MAIL_PORT ?? "5303", 10),
|
|
});
|
|
process.stdout.write(`Local SMTP fixture ready on 127.0.0.1:${fixture.smtpPort}; control 127.0.0.1:${fixture.controlPort}\n`);
|
|
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void fixture.close(); });
|
|
}
|