Repository Public
1import { appendFileSync } from "node:fs";2import nodemailer, { type Transporter } from "nodemailer";3import { env, mailConfigured } from "../env";45export interface OutgoingMail {6 to: string; subject: string; text: string; html: string; replyTo?: string | null;7 /** Stable key: the provider drops a second send of the same message (Resend Idempotency-Key). */8 idempotencyKey: string;9 ics?: { filename: string; content: string; method: "REQUEST" | "CANCEL" } | null;10}11export interface SendResult { transport: string; messageId: string }1213/** Raised when no transport is configured: the job is parked as "unconfigured", nothing is pretended. */14export class MailNotConfigured extends Error {}1516/** Test transport: messages are kept in memory (and appended to SPCAL_TEST_OUTBOX when set). Refused in production. */17export const testOutbox: OutgoingMail[] = [];18export const testControl = { failNext: 0 };1920async function sendResend(m: OutgoingMail): Promise<SendResult> {21 const res = await fetch("https://api.resend.com/emails", {22 method: "POST",23 headers: { Authorization: `Bearer ${env.resendApiKey}`, "Content-Type": "application/json", "Idempotency-Key": m.idempotencyKey.slice(0, 256) },24 body: JSON.stringify({25 from: env.mailFrom, to: [m.to], subject: m.subject, text: m.text, html: m.html,26 ...(m.replyTo ? { reply_to: m.replyTo } : {}),27 ...(m.ics ? { attachments: [{ filename: m.ics.filename, content: Buffer.from(m.ics.content).toString("base64"), content_type: `text/calendar; charset=utf-8; method=${m.ics.method}` }] } : {}),28 }),29 signal: AbortSignal.timeout(20_000),30 });31 const bodyText = await res.text();32 if (!res.ok) throw new Error(`Resend HTTP ${res.status}: ${bodyText.slice(0, 300)}`);33 const id = (JSON.parse(bodyText) as { id?: string }).id;34 if (!id) throw new Error("Resend accepted the request without returning a message id");35 return { transport: "resend", messageId: id };36}3738let smtp: Transporter | null = null;39async function sendSmtp(m: OutgoingMail): Promise<SendResult> {40 smtp ??= nodemailer.createTransport(env.smtpUrl!);41 const info = await smtp.sendMail({42 from: env.mailFrom, to: m.to, subject: m.subject, text: m.text, html: m.html, replyTo: m.replyTo ?? undefined,43 ...(m.ics ? { icalEvent: { filename: m.ics.filename, method: m.ics.method, content: m.ics.content } } : {}),44 });45 if (info.rejected?.length) throw new Error(`SMTP rejected the recipient: ${String(info.response).slice(0, 200)}`);46 return { transport: "smtp", messageId: info.messageId };47}4849/** Resolves only when the transport has accepted the message; anything else throws. */50export async function sendMail(m: OutgoingMail): Promise<SendResult> {51 const cfg = mailConfigured();52 if (!cfg.ok) throw new MailNotConfigured(cfg.reason);53 if (cfg.transport === "resend") return sendResend(m);54 if (cfg.transport === "smtp") return sendSmtp(m);55 // test56 if (testControl.failNext > 0 || m.to.startsWith("bounce@")) { if (testControl.failNext > 0) testControl.failNext--; throw new Error("test transport: simulated failure"); }57 testOutbox.push(m);58 if (process.env.SPCAL_TEST_OUTBOX) appendFileSync(process.env.SPCAL_TEST_OUTBOX, JSON.stringify(m) + "\n");59 return { transport: "test", messageId: `test-${testOutbox.length}-${Date.now()}` };60}61