import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { EmailService } from "../../../src/service/emailService.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; import type { EmailTransportConfig } from "../../../src/domain/app-settings/app-settings.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── const makeTransportConfig = (overrides?: Partial): EmailTransportConfig => ({ systemEmailHost: "smtp.example.com", systemEmailPort: 587, systemEmailSecure: false, systemEmailPool: false, systemEmailUser: "user@example.com", systemEmailAddress: "noreply@example.com", systemEmailPassword: "password123", systemEmailConnectionHost: "mail.example.com", systemEmailTLSServername: "smtp.example.com", systemEmailIgnoreTLS: false, systemEmailRequireTLS: true, systemEmailRejectUnauthorized: true, ...overrides, }) as EmailTransportConfig; const createMockSettingsService = () => ({ getDBSettings: jest.fn().mockResolvedValue(makeTransportConfig()), }) as unknown as jest.Mocked; const createMockFs = (templateContent: string = "") => ({ readFileSync: jest.fn().mockReturnValue(templateContent), }) as any; const createMockPath = () => ({ join: jest.fn((...args: string[]) => args.join("/")), dirname: jest.fn().mockReturnValue("/mock/dir"), }) as any; const createMockCompile = (result: string = "compiled") => { const compiledFn = jest.fn().mockReturnValue(result); const compile = jest.fn().mockReturnValue(compiledFn) as any; compile.__compiledFn = compiledFn; return compile; }; const createMockMjml = (html: string = "rendered") => jest.fn().mockReturnValue({ html }) as any; const createMockTransporter = () => ({ verify: jest.fn().mockResolvedValue(true), sendMail: jest.fn().mockResolvedValue({ messageId: "msg-123" }), }); const createMockNodemailer = (transporter?: ReturnType) => { const t = transporter ?? createMockTransporter(); return { createTransport: jest.fn().mockReturnValue(t), __transporter: t, } as any; }; const createService = (overrides?: { settingsService?: any; fs?: any; path?: any; compile?: any; mjml?: any; nodemailer?: any }) => { const logger = createMockLogger(); const settingsService = overrides?.settingsService ?? createMockSettingsService(); const mockFs = overrides?.fs ?? createMockFs(); const mockPath = overrides?.path ?? createMockPath(); const compile = overrides?.compile ?? createMockCompile(); const mjml = overrides?.mjml ?? createMockMjml(); const mockNodemailer = overrides?.nodemailer ?? createMockNodemailer(); const service = new EmailService(settingsService, mockFs, mockPath, compile, mjml, mockNodemailer, logger as any); return { service, logger, settingsService, mockFs, mockPath, compile, mjml, mockNodemailer }; }; // ── Tests ──────────────────────────────────────────────────────────────────── describe("EmailService", () => { describe("SERVICE_NAME", () => { it("returns EmailService from static property", () => { expect(EmailService.SERVICE_NAME).toBe("EmailService"); }); }); // ── init / loadTemplate ────────────────────────────────────────────── describe("init", () => { it("loads all expected templates on construction", () => { const { mockFs } = createService(); // 5 templates loaded expect(mockFs.readFileSync).toHaveBeenCalledTimes(5); }); it("compiles each loaded template", () => { const { compile } = createService(); expect(compile).toHaveBeenCalledTimes(5); }); it("logs error when a template file is not found", () => { const failFs = { readFileSync: jest.fn().mockImplementation(() => { throw new Error("ENOENT: no such file"); }), }; const { logger } = createService({ fs: failFs }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "ENOENT: no such file", service: "EmailService", method: "loadTemplate", }) ); }); it("logs 'Unknown error' for non-Error thrown values in loadTemplate", () => { const failFs = { readFileSync: jest.fn().mockImplementation(() => { throw "string error"; }), }; const { logger } = createService({ fs: failFs }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Unknown error", stack: undefined, }) ); }); it("can be called again to reinitialize templates", () => { const { service, mockFs } = createService(); const initialCalls = mockFs.readFileSync.mock.calls.length; service.init(); expect(mockFs.readFileSync).toHaveBeenCalledTimes(initialCalls + 5); }); }); // ── buildEmail ─────────────────────────────────────────────────────── describe("buildEmail", () => { it("compiles template with context and returns rendered HTML", async () => { const { service } = createService(); const result = await service.buildEmail("welcomeEmailTemplate", { name: "Test" }); expect(result).toBe("rendered"); }); it("passes context to the compiled template function", async () => { const compile = createMockCompile(); const { service } = createService({ compile }); const context = { name: "Alex", url: "https://example.com" }; await service.buildEmail("welcomeEmailTemplate", context); expect(compile.__compiledFn).toHaveBeenCalledWith(context); }); it("returns undefined and logs error when template is not found", async () => { const { service, logger } = createService(); const result = await service.buildEmail("nonExistentTemplate", {}); expect(result).toBeUndefined(); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Template nonExistentTemplate not found", service: "EmailService", method: "buildEmail", }) ); }); it("returns undefined and logs error when template function returns falsy", async () => { const compile = jest.fn().mockReturnValue(jest.fn().mockReturnValue(undefined)) as any; const { service, logger } = createService({ compile }); const result = await service.buildEmail("welcomeEmailTemplate", {}); expect(result).toBeUndefined(); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Template welcomeEmailTemplate not found", }) ); }); it("returns undefined and logs error when mjml throws", async () => { const mjml = jest.fn().mockImplementation(() => { throw new Error("MJML parse error"); }) as any; const { service, logger } = createService({ mjml }); const result = await service.buildEmail("welcomeEmailTemplate", {}); expect(result).toBeUndefined(); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "MJML parse error", method: "buildEmail", }) ); }); it("logs 'Unknown error' for non-Error thrown values in buildEmail", async () => { const mjml = jest.fn().mockImplementation(() => { throw null; }) as any; const { service, logger } = createService({ mjml }); const result = await service.buildEmail("welcomeEmailTemplate", {}); expect(result).toBeUndefined(); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Unknown error", method: "buildEmail", stack: undefined, }) ); }); }); // ── sendEmail ──────────────────────────────────────────────────────── describe("sendEmail", () => { it("sends email with provided transport config and returns messageId", async () => { const transporter = createMockTransporter(); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ nodemailer }); const config = makeTransportConfig(); const result = await service.sendEmail("to@example.com", "Subject", "

body

", config); expect(result).toBe("msg-123"); expect(transporter.verify).toHaveBeenCalled(); expect(transporter.sendMail).toHaveBeenCalledWith({ to: "to@example.com", from: "noreply@example.com", subject: "Subject", html: "

body

", }); }); it("falls back to DB settings when no transport config is provided", async () => { const settingsService = createMockSettingsService(); const transporter = createMockTransporter(); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ settingsService, nodemailer }); await service.sendEmail("to@example.com", "Subject", "

body

"); expect(settingsService.getDBSettings).toHaveBeenCalled(); }); it("creates transporter with correct email config", async () => { const transporter = createMockTransporter(); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ nodemailer }); const config = makeTransportConfig({ systemEmailHost: "smtp.test.com", systemEmailPort: 465, systemEmailSecure: true, systemEmailPool: true, systemEmailUser: "testuser", systemEmailPassword: "testpass", systemEmailConnectionHost: "conn.test.com", systemEmailRejectUnauthorized: false, systemEmailIgnoreTLS: true, systemEmailRequireTLS: false, systemEmailTLSServername: "tls.test.com", }); await service.sendEmail("to@example.com", "Subject", "

body

", config); expect(nodemailer.createTransport).toHaveBeenCalledWith({ host: "smtp.test.com", port: 465, secure: true, auth: { user: "testuser", pass: "testpass" }, name: "conn.test.com", connectionTimeout: 5000, pool: true, tls: { rejectUnauthorized: false, ignoreTLS: true, requireTLS: false, servername: "tls.test.com", }, }); }); it("uses systemEmailAddress as auth user when systemEmailUser is falsy", async () => { const transporter = createMockTransporter(); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ nodemailer }); const config = makeTransportConfig({ systemEmailUser: "", systemEmailAddress: "fallback@example.com" }); await service.sendEmail("to@example.com", "Subject", "

body

", config); expect(nodemailer.createTransport).toHaveBeenCalledWith( expect.objectContaining({ auth: expect.objectContaining({ user: "fallback@example.com" }), }) ); }); it("uses 'localhost' as name when systemEmailConnectionHost is falsy", async () => { const transporter = createMockTransporter(); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ nodemailer }); const config = makeTransportConfig({ systemEmailConnectionHost: "" }); await service.sendEmail("to@example.com", "Subject", "

body

", config); expect(nodemailer.createTransport).toHaveBeenCalledWith(expect.objectContaining({ name: "localhost" })); }); // Callers wrap sendEmail in .catch()/try-catch to react to delivery failures, so a // transport error has to reject rather than resolve to a falsy value. it("throws when transporter verification fails", async () => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); await expect(service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig())).rejects.toThrow( "SMTP host smtp.example.com:587 failed verification." ); expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ message: "Email transporter verification failed: SMTP auth failed", service: "EmailService", method: "verifyTransporter", details: { host: "smtp.example.com", port: 587 }, }) ); }); it.each([ ["EAUTH", "SMTP host smtp.example.com:587 rejected the login. Check the user name and password."], ["ETIMEDOUT", "SMTP host smtp.example.com:587 did not respond. Check the port and the secure setting."], ["EDNS", "SMTP host smtp.example.com:587 could not be resolved. Check the host name."], ["ECONNECTION", "SMTP host smtp.example.com:587 refused the connection. Check the host and port."], ["ETLS", "SMTP host smtp.example.com:587 failed the TLS handshake. Check the secure and TLS settings."], ])("describes a %s verification failure without the server's reply text", async (code, expected) => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue(Object.assign(new Error("535-5.7.8 Username and Password not accepted"), { code })); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ nodemailer }); await expect(service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig())).rejects.toMatchObject({ message: expected, status: 502, details: { cause: "535-5.7.8 Username and Password not accepted" }, }); }); it("throws a 400 without building a transport when the host is not configured", async () => { const nodemailer = createMockNodemailer(); const { service } = createService({ nodemailer }); await expect( service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig({ systemEmailHost: undefined })) ).rejects.toMatchObject({ status: 400, message: "Email is not configured. Set the system email host in settings.", }); expect(nodemailer.createTransport).not.toHaveBeenCalled(); }); it("treats an empty host as not configured", async () => { const { service } = createService(); await expect(service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig({ systemEmailHost: "" }))).rejects.toMatchObject( { status: 400, } ); }); it("reports the underlying cause when verification fails", async () => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue(new Error("SMTP auth failed")); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ nodemailer }); await expect(service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig())).rejects.toMatchObject({ status: 502, details: { cause: "SMTP auth failed" }, }); }); it("logs stack as undefined when verification fails with non-Error", async () => { const transporter = createMockTransporter(); (transporter.verify as jest.Mock).mockRejectedValue("connection refused"); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); await expect(service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig())).rejects.toThrow(); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ stack: undefined })); }); it("throws and logs error when sendMail throws", async () => { const transporter = createMockTransporter(); (transporter.sendMail as jest.Mock).mockRejectedValue(new Error("Recipient rejected")); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); await expect(service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig())).rejects.toMatchObject({ message: "Failed to send email", details: { cause: "Recipient rejected" }, }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Recipient rejected", service: "EmailService", method: "sendEmail", }) ); }); it("logs 'Unknown error' for non-Error thrown values in sendMail", async () => { const transporter = createMockTransporter(); (transporter.sendMail as jest.Mock).mockRejectedValue(42); const nodemailer = createMockNodemailer(transporter); const { service, logger } = createService({ nodemailer }); await expect(service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig())).rejects.toMatchObject({ details: { cause: "Unknown error" }, }); expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ message: "Unknown error", method: "sendEmail", stack: undefined, }) ); }); // ── from-header formatting ─────────────────────────────────────── describe("from header formatting", () => { const sendAndGetFrom = async (overrides: Partial) => { const transporter = createMockTransporter(); const nodemailer = createMockNodemailer(transporter); const { service } = createService({ nodemailer }); await service.sendEmail("to@example.com", "Subject", "

body

", makeTransportConfig(overrides)); const call = (transporter.sendMail as jest.Mock).mock.calls[0]?.[0] as { from: unknown } | undefined; return call?.from; }; it("uses bare address when systemEmailDisplayName is undefined", async () => { const from = await sendAndGetFrom({ systemEmailAddress: "noreply@example.com", systemEmailDisplayName: undefined, }); expect(from).toBe("noreply@example.com"); }); it("uses bare address when systemEmailDisplayName is empty string", async () => { const from = await sendAndGetFrom({ systemEmailAddress: "noreply@example.com", systemEmailDisplayName: "", }); expect(from).toBe("noreply@example.com"); }); it("uses bare address when systemEmailDisplayName is whitespace-only", async () => { const from = await sendAndGetFrom({ systemEmailAddress: "noreply@example.com", systemEmailDisplayName: " ", }); expect(from).toBe("noreply@example.com"); }); it("uses {name, address} when display name and address are both set", async () => { const from = await sendAndGetFrom({ systemEmailAddress: "noreply@example.com", systemEmailDisplayName: "Checkmate notifications", }); expect(from).toEqual({ name: "Checkmate notifications", address: "noreply@example.com", }); }); it("trims surrounding whitespace from display name", async () => { const from = await sendAndGetFrom({ systemEmailAddress: "noreply@example.com", systemEmailDisplayName: " Checkmate ", }); expect(from).toEqual({ name: "Checkmate", address: "noreply@example.com", }); }); it("falls back to bare address when display name is set but address is empty", async () => { const from = await sendAndGetFrom({ systemEmailAddress: "", systemEmailDisplayName: "Checkmate", }); expect(from).toBe(""); }); }); }); });