import { createHash } from "node:crypto"; import nodemailer from "nodemailer"; import { Resend } from "resend"; import { EMAIL_LOGO_CID, emailLogoAttachment } from "./logo"; export { emailBrowserPreview } from "./logo"; import { brandPalette as palette } from "@album/contracts"; const PRIMARY = palette.primary; function escapeHtml(value: string) { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function referenceHash(value: string) { return createHash("sha256").update(value).digest("hex").slice(0, 16); } export function chrome(bodyHtml: string) { return `
Manyangles

Every angle. One shared album.

${bodyHtml}
Manyangles

Keep the moment.

Hadlock Technologies LLC

`; } export function getEmailReadiness() { const provider = process.env.EMAIL_PROVIDER ?? "resend"; const missing: string[] = []; if (!process.env.EMAIL_FROM && !process.env.RESEND_FROM) { missing.push("EMAIL_FROM"); } if (provider === "resend") { if (!process.env.RESEND_API_KEY) missing.push("RESEND_API_KEY"); } else if (provider === "mailpit" || provider === "smtp") { if (provider === "mailpit" && process.env.NODE_ENV === "production") { missing.push("EMAIL_PROVIDER (mailpit is development-only)"); } if (!process.env.SMTP_HOST) missing.push("SMTP_HOST"); if (!process.env.SMTP_PORT) missing.push("SMTP_PORT"); } else { missing.push("EMAIL_PROVIDER"); } return { provider, configured: missing.length === 0, missing, }; } export async function sendEmail(input: { to: string; subject: string; html: string; text: string; referenceId: string; from?: string; attachments?: { filename: string; content: string; contentId: string }[]; }, options?: { provider?: string; idempotencyKey?: string }) { const attachments = input.attachments ?? (input.html.includes(`cid:${EMAIL_LOGO_CID}`) ? [emailLogoAttachment] : []); const provider = options?.provider ?? process.env.EMAIL_PROVIDER ?? "resend"; if (options?.provider && provider !== (process.env.EMAIL_PROVIDER ?? "resend")) throw new Error("Queued email provider differs from configured provider"); if (!["mailpit", "smtp", "resend"].includes(provider)) throw new Error("Unsupported email provider"); if (provider === "mailpit" && process.env.NODE_ENV === "production") throw new Error("Mailpit is development-only"); const from = input.from ?? process.env.EMAIL_FROM ?? process.env.RESEND_FROM ?? "Manyangles "; const readiness = getEmailReadiness(); if (process.env.NODE_ENV === "production" && !readiness.configured) { throw new Error( `Email delivery is not configured: ${readiness.missing.join(", ")}`, ); } if (provider === "mailpit" || provider === "smtp") { const transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST ?? "127.0.0.1", port: Number(process.env.SMTP_PORT ?? 1025), secure: process.env.SMTP_SECURE === "true", connectionTimeout: 15_000, socketTimeout: 30_000, }); const result = await transporter.sendMail({ from, to: input.to, subject: input.subject, html: input.html, text: input.text, headers: { "X-Entity-Ref-ID": input.referenceId }, attachments: attachments.map((attachment) => ({ filename: attachment.filename, content: Buffer.from(attachment.content, "base64"), cid: attachment.contentId, contentType: "image/png", contentDisposition: "inline" as const })), }); return { id: result.messageId }; } const apiKey = process.env.RESEND_API_KEY; if (!apiKey) { throw new Error("RESEND_API_KEY is required; use Mailpit for local email"); } const resend = new Resend(apiKey); const { data, error } = await resend.emails.send({ from, to: input.to, subject: input.subject, html: input.html, text: input.text, headers: { "X-Entity-Ref-ID": input.referenceId }, attachments, }, { idempotencyKey: options?.idempotencyKey ?? `${input.referenceId}-${referenceHash(input.to.toLowerCase())}` }); if (error) throw new Error(error.message); return { id: data?.id ?? input.referenceId }; } export async function sendAccountVerificationEmail(message: { to: string; name: string; verificationUrl: string; }) { return sendEmail({ to: message.to, subject: "Verify your Manyangles account", html: chrome(`

Verify your email

Hi ${escapeHtml(message.name)},

Confirm this address to finish creating your Manyangles host account.

Verify email

`), text: `Verify your Manyangles email: ${message.verificationUrl}`, referenceId: `account-verification-${referenceHash(message.verificationUrl)}`, }); } export async function sendPasswordResetEmail(message: { to: string; name: string; resetUrl: string; }) { return sendEmail({ to: message.to, subject: "Reset your Manyangles password", html: chrome(`

Reset your password

Hi ${escapeHtml(message.name)},

Use the secure link below to choose a new password. The link expires in one hour.

Reset password

If you did not request this, you can ignore this message.

`), text: `Reset your Manyangles password: ${message.resetUrl}\n\nThis link expires in one hour.`, referenceId: `password-reset-${referenceHash(message.resetUrl)}`, }); } export async function sendStaffInviteEmail(message: { to: string; inviterName: string; inviteUrl: string; }) { return sendEmail({ to: message.to, subject: "You're invited to help with a Manyangles event", html: chrome(`

You're invited

${escapeHtml(message.inviterName)} invited you to help manage photos for an event.

Accept invite

`), text: `${message.inviterName} invited you to help with a Manyangles event: ${message.inviteUrl}`, referenceId: `staff-invite-${referenceHash(message.inviteUrl)}`, }); } export function renderAlbumReadyEmail(message: { to: string; eventTitle: string; galleryUrl: string; }) { return { from: process.env.EMAIL_FROM ?? process.env.RESEND_FROM ?? "Manyangles ", attachments: [emailLogoAttachment], to: message.to, subject: `The gallery for ${message.eventTitle} is ready`, html: chrome(`

The gallery is ready

Photos from ${escapeHtml(message.eventTitle)} are now available to view.

View gallery

`), text: `The gallery for ${message.eventTitle} is ready: ${message.galleryUrl}`, referenceId: `album-ready-${referenceHash(message.galleryUrl)}`, }; } export async function sendAlbumReadyEmail(message: { to: string; eventTitle: string; galleryUrl: string }) { return sendEmail(renderAlbumReadyEmail(message)); }