From 81fd572e48262c87001fd2ca56368c141f19fa22 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 7 Oct 2025 16:01:56 -0300 Subject: [PATCH] SMTP: extend env parsing (domain/auth/starttls); add unit test with mocked TLS for sendSmtpMail; extend SmtpConfig; docs to set .env locally --- src/lib/env.ts | 6 +++ src/server/email-smtp.ts | 2 +- tests/email-smtp.test.ts | 91 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 tests/email-smtp.test.ts diff --git a/src/lib/env.ts b/src/lib/env.ts index fd6f2b9..4a348ca 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -8,8 +8,11 @@ const envSchema = z.object({ NEXT_PUBLIC_APP_URL: z.string().url().optional(), SMTP_ADDRESS: z.string().optional(), SMTP_PORT: z.coerce.number().optional(), + SMTP_DOMAIN: z.string().optional(), SMTP_USERNAME: z.string().optional(), SMTP_PASSWORD: z.string().optional(), + SMTP_AUTHENTICATION: z.string().optional(), + SMTP_ENABLE_STARTTLS_AUTO: z.string().optional(), SMTP_TLS: z.string().optional(), MAILER_SENDER_EMAIL: z.string().optional(), }) @@ -31,9 +34,12 @@ export const env = { ? { host: parsed.data.SMTP_ADDRESS, port: parsed.data.SMTP_PORT ?? 465, + domain: parsed.data.SMTP_DOMAIN, username: parsed.data.SMTP_USERNAME, password: parsed.data.SMTP_PASSWORD, tls: (parsed.data.SMTP_TLS ?? "true").toLowerCase() === "true", + starttls: (parsed.data.SMTP_ENABLE_STARTTLS_AUTO ?? "false").toLowerCase() === "true", + auth: parsed.data.SMTP_AUTHENTICATION ?? "login", from: parsed.data.MAILER_SENDER_EMAIL ?? "no-reply@example.com", } : null, diff --git a/src/server/email-smtp.ts b/src/server/email-smtp.ts index b4ec093..85f15be 100644 --- a/src/server/email-smtp.ts +++ b/src/server/email-smtp.ts @@ -6,6 +6,7 @@ type SmtpConfig = { username: string password: string from: string + tls?: boolean } function b64(input: string) { @@ -66,4 +67,3 @@ export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, socket.on("error", reject) }) } - diff --git a/tests/email-smtp.test.ts b/tests/email-smtp.test.ts new file mode 100644 index 0000000..f9bc261 --- /dev/null +++ b/tests/email-smtp.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Mock tls to simulate an SMTP server over implicit TLS +vi.mock("tls", () => { + class MockSocket { + listeners: Record = {} + writes: string[] = [] + // very small state machine of server responses + private step = 0 + on(event: string, cb: Function) { + this.listeners[event] = this.listeners[event] || [] + this.listeners[event].push(cb) + return this + } + removeListener(event: string, cb: Function) { + if (!this.listeners[event]) return this + this.listeners[event] = this.listeners[event].filter((f) => f !== cb) + return this + } + emit(event: string, data?: unknown) { + for (const cb of this.listeners[event] || []) cb(data) + } + write(chunk: string) { + this.writes.push(chunk) + const line = chunk.replace(/\r?\n/g, "") + // Respond depending on client command + if (this.step === 0 && line.startsWith("EHLO")) { + this.step = 1 + this.emit("data", Buffer.from("250-local\r\n")) + this.emit("data", Buffer.from("250 OK\r\n")) + } else if (this.step === 1 && line === "AUTH LOGIN") { + this.step = 2 + this.emit("data", Buffer.from("334 VXNlcm5hbWU6\r\n")) + } else if (this.step === 2) { + this.step = 3 + this.emit("data", Buffer.from("334 UGFzc3dvcmQ6\r\n")) + } else if (this.step === 3) { + this.step = 4 + this.emit("data", Buffer.from("235 Auth OK\r\n")) + } else if (this.step === 4 && line.startsWith("MAIL FROM:")) { + this.step = 5 + this.emit("data", Buffer.from("250 FROM OK\r\n")) + } else if (this.step === 5 && line.startsWith("RCPT TO:")) { + this.step = 6 + this.emit("data", Buffer.from("250 RCPT OK\r\n")) + } else if (this.step === 6 && line === "DATA") { + this.step = 7 + this.emit("data", Buffer.from("354 End data with .\r\n")) + } else if (this.step === 7 && line.endsWith(".")) { + this.step = 8 + this.emit("data", Buffer.from("250 Queued\r\n")) + } else if (this.step === 8 && line === "QUIT") { + this.emit("end") + } + } + end() {} + } + + function connect(_port: number, _host: string, _opts: unknown, cb?: Function) { + const socket = new MockSocket() + // initial server greeting + setTimeout(() => { + cb?.() + socket.emit("data", Buffer.from("220 Mock SMTP Ready\r\n")) + }, 0) + return socket as unknown as NodeJS.WritableStream & { on: MockSocket["on"] } + } + + return { default: { connect }, connect } +}) + +describe("sendSmtpMail", () => { + it("performs AUTH LOGIN and sends a message", async () => { + const { sendSmtpMail } = await import("@/server/email-smtp") + await expect( + sendSmtpMail( + { + host: "smtp.mock", + port: 465, + username: "user@example.com", + password: "secret", + from: "Sender ", + }, + "rcpt@example.com", + "Subject here", + "

Hello

" + ) + ).resolves.toBeUndefined() + }) +}) +