1import { describe, expect, it } from "vitest";
2import { buildMessage, dotStuff, SmtpSession, SmtpError } from "../lib/smtp.js";
3
4
15
16import { EventEmitter } from "node:events";
17
18class FakeSocket extends EventEmitter {
19 written: string[] = [];
20 write(data: string): boolean {
21 this.written.push(data);
22 return true;
23 }
24 end(): void {
25 this.emit("close");
26 }
27 destroy(): void {
28 this.emit("close");
29 }
30}
31
32function makeSession(script: string[]): {
33 session: SmtpSession;
34 socket: FakeSocket;
35} {
36 const socket = new FakeSocket() as unknown as FakeSocket & {
37 write: (s: string) => boolean;
38 };
39 const session = new SmtpSession(socket as never);
40
41 for (const line of script) {
42 socket.emit("data", Buffer.from(line + "\r\n"));
43 }
44 return { session, socket };
45}
46
47describe("SmtpSession reply handling", () => {
48 it("single-line reply resolves the pending expect()", async () => {
49 const { session, socket } = makeSession([]);
50 const p = session.expect(1000);
51 socket.emit("data", Buffer.from("220 mail.example.com ESMTP\r\n"));
52 await expect(p).resolves.toBe("220 mail.example.com ESMTP");
53 expect(session.codeOf("220 x")).toBe(220);
54 });
55
56 it("multi-line 250 reply (space vs hyphen continuation) joins to one reply", async () => {
57 const { session, socket } = makeSession([]);
58 const p = session.expect(1000);
59 socket.emit("data", Buffer.from("250-OK\r\n250 ready to go\r\n"));
60 await expect(p).resolves.toBe("250-OK\n250 ready to go");
61 expect(session.codeOf("250-OK\n250 ready to go")).toBe(250);
62 });
63
64 it("writes commands with CRLF", async () => {
65 const { session, socket } = makeSession([]);
66 session.write("EHLO factory.local");
67 expect(socket.written).toEqual(["EHLO factory.local\r\n"]);
68 });
69
70 it("rejects a second concurrent expect()", async () => {
71 const { session, socket } = makeSession([]);
72 const p1 = session.expect(1000);
73 await expect(session.expect(1000)).rejects.toBeInstanceOf(SmtpError);
74 socket.emit("data", Buffer.from("220 banner\r\n"));
75 await expect(p1).resolves.toBe("220 banner");
76 });
77
78 it("socket error rejects the pending expect", async () => {
79 const { session, socket } = makeSession([]);
80 const p = session.expect(1000);
81 socket.emit("error", new Error("connection reset"));
82 await expect(p).rejects.toThrow("connection reset");
83 });
84
85 it("socket close rejects the pending expect", async () => {
86 const { session, socket } = makeSession([]);
87 const p = session.expect(1000);
88 socket.emit("close");
89 await expect(p).rejects.toThrow(/closed/);
90 });
91});
92
93describe("dotStuff (RFC 5321 §4.5.2)", () => {
94 it("prefixes lines starting with a dot", () => {
95 expect(dotStuff("a\r\n.b\r\n..c")).toBe("a\r\n..b\r\n...c");
96 });
97 it("leaves normal lines alone", () => {
98 expect(dotStuff("hello\r\nworld")).toBe("hello\r\nworld");
99 });
100});
101
102describe("buildMessage", () => {
103 it("produces a multipart/alternative message with base64 parts", () => {
104 const msg = buildMessage({
105 from: "noreply@factory.local",
106 to: ["a@b.c"],
107 subject: "Hi",
108 html: "<p>hi</p>",
109 text: "hi"
110 });
111 expect(msg).toContain("From: noreply@factory.local");
112 expect(msg).toContain("To: a@b.c");
113 expect(msg).toContain("Subject: Hi");
114 expect(msg).toContain("Content-Type: multipart/alternative; boundary=");
115 expect(msg).toContain("Content-Transfer-Encoding: base64");
116
117 expect(msg).toContain(Buffer.from("hi").toString("base64"));
118 expect(msg).toContain("--");
119 });
120
121 it("html-only message contains only a text/html part", () => {
122 const msg = buildMessage({
123 from: "a@b.c",
124 to: ["x@y.z"],
125 subject: "s",
126 html: "<b>bold</b>"
127 });
128 expect(msg).toContain("text/html");
129 expect(msg).not.toContain("text/plain");
130 });
131
132 it("subject with non-ASCII is RFC 2047 encoded", () => {
133 const msg = buildMessage({
134 from: "a@b.c",
135 to: ["x@y.z"],
136 subject: "Héllo wörld"
137 });
138 expect(msg).toMatch(/Subject: =\?utf-8\?B\?/);
139 expect(msg).not.toContain("Subject: Héllo");
140 });
141});
142