Files
jobtrackingapp/job-tracker-ui/scripts/localSMTPfixture.mjs
T

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(); });
}