SMTP: extend env parsing (domain/auth/starttls); add unit test with mocked TLS for sendSmtpMail; extend SmtpConfig; docs to set .env locally
This commit is contained in:
parent
53c76a0289
commit
81fd572e48
3 changed files with 98 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
91
tests/email-smtp.test.ts
Normal file
91
tests/email-smtp.test.ts
Normal file
|
|
@ -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<string, Function[]> = {}
|
||||
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 <CR><LF>.<CR><LF>\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 <sender@example.com>",
|
||||
},
|
||||
"rcpt@example.com",
|
||||
"Subject here",
|
||||
"<p>Hello</p>"
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue