import { describe, expect, it } from "vitest"; import { buildMessage, dotStuff, SmtpSession, SmtpError } from "../lib/smtp.js"; /** * SMTP command-sequence tests. * * We drive the stateful SmtpSession with a scripted fake socket (writable + * EventEmitter) instead of opening a real TCP connection: each test feeds the * server responses a real server would send and asserts the client accepts * exactly the expected protocol steps (banner → EHLO → MAIL FROM → RCPT TO → * DATA → body → QUIT), including multi-line 250 replies and the STARTTLS * branch decision. The full socket wiring in smtpSend() follows this same * session, so the sequence is the part under test. */ import { EventEmitter } from "node:events"; class FakeSocket extends EventEmitter { written: string[] = []; write(data: string): boolean { this.written.push(data); return true; } end(): void { this.emit("close"); } destroy(): void { this.emit("close"); } } function makeSession(script: string[]): { session: SmtpSession; socket: FakeSocket; } { const socket = new FakeSocket() as unknown as FakeSocket & { write: (s: string) => boolean; }; const session = new SmtpSession(socket as never); // Feed the scripted lines one at a time. for (const line of script) { socket.emit("data", Buffer.from(line + "\r\n")); } return { session, socket }; } describe("SmtpSession reply handling", () => { it("single-line reply resolves the pending expect()", async () => { const { session, socket } = makeSession([]); const p = session.expect(1000); socket.emit("data", Buffer.from("220 mail.example.com ESMTP\r\n")); await expect(p).resolves.toBe("220 mail.example.com ESMTP"); expect(session.codeOf("220 x")).toBe(220); }); it("multi-line 250 reply (space vs hyphen continuation) joins to one reply", async () => { const { session, socket } = makeSession([]); const p = session.expect(1000); socket.emit("data", Buffer.from("250-OK\r\n250 ready to go\r\n")); await expect(p).resolves.toBe("250-OK\n250 ready to go"); expect(session.codeOf("250-OK\n250 ready to go")).toBe(250); }); it("writes commands with CRLF", async () => { const { session, socket } = makeSession([]); session.write("EHLO factory.local"); expect(socket.written).toEqual(["EHLO factory.local\r\n"]); }); it("rejects a second concurrent expect()", async () => { const { session, socket } = makeSession([]); const p1 = session.expect(1000); await expect(session.expect(1000)).rejects.toBeInstanceOf(SmtpError); socket.emit("data", Buffer.from("220 banner\r\n")); await expect(p1).resolves.toBe("220 banner"); }); it("socket error rejects the pending expect", async () => { const { session, socket } = makeSession([]); const p = session.expect(1000); socket.emit("error", new Error("connection reset")); await expect(p).rejects.toThrow("connection reset"); }); it("socket close rejects the pending expect", async () => { const { session, socket } = makeSession([]); const p = session.expect(1000); socket.emit("close"); await expect(p).rejects.toThrow(/closed/); }); }); describe("dotStuff (RFC 5321 §4.5.2)", () => { it("prefixes lines starting with a dot", () => { expect(dotStuff("a\r\n.b\r\n..c")).toBe("a\r\n..b\r\n...c"); }); it("leaves normal lines alone", () => { expect(dotStuff("hello\r\nworld")).toBe("hello\r\nworld"); }); }); describe("buildMessage", () => { it("produces a multipart/alternative message with base64 parts", () => { const msg = buildMessage({ from: "noreply@factory.local", to: ["a@b.c"], subject: "Hi", html: "

hi

", text: "hi" }); expect(msg).toContain("From: noreply@factory.local"); expect(msg).toContain("To: a@b.c"); expect(msg).toContain("Subject: Hi"); expect(msg).toContain("Content-Type: multipart/alternative; boundary="); expect(msg).toContain("Content-Transfer-Encoding: base64"); // base64 of "hi" expect(msg).toContain(Buffer.from("hi").toString("base64")); expect(msg).toContain("--"); // boundary present }); it("html-only message contains only a text/html part", () => { const msg = buildMessage({ from: "a@b.c", to: ["x@y.z"], subject: "s", html: "bold" }); expect(msg).toContain("text/html"); expect(msg).not.toContain("text/plain"); }); it("subject with non-ASCII is RFC 2047 encoded", () => { const msg = buildMessage({ from: "a@b.c", to: ["x@y.z"], subject: "Héllo wörld" }); expect(msg).toMatch(/Subject: =\?utf-8\?B\?/); expect(msg).not.toContain("Subject: Héllo"); }); });