SPB Git

Repository Public

transport.ts 3.2 KB 61 lines typescript

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
Files main
Menu

Theme