243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
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 `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<link href="https://fonts.googleapis.com/css2?family=Funnel+Display:wght@500;600;700&family=Geologica:wght@400;600;700&display=swap" rel="stylesheet">
|
|
<style>h1,h2 {font-family:'Funnel Display',Arial,Helvetica,sans-serif;line-height:1.15;letter-spacing:-.035em} @media(max-width:480px){body{padding:12px 6px!important}}</style>
|
|
</head>
|
|
<body style="margin:0;padding:24px 12px;background:${palette.background}">
|
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;color:${palette.ink};background:${palette.surface};font-family:'Geologica',Arial,Helvetica,sans-serif"><tr><td>
|
|
<div style="padding:20px 24px;border:1px solid ${palette.border};border-top:6px solid ${PRIMARY}">
|
|
<table role="presentation" cellspacing="0" cellpadding="0"><tr>
|
|
<td style="vertical-align:middle;padding-right:8px"><img src="cid:${EMAIL_LOGO_CID}" width="32" height="32" alt="" style="display:block;border:0;width:32px;height:32px"></td>
|
|
<td style="vertical-align:middle"><strong style="font-size:26px;line-height:32px;letter-spacing:-.04em;color:${palette.ink}">Manyangles</strong></td>
|
|
</tr></table>
|
|
<p style="margin:8px 0 0;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:${palette.muted}">Every angle. One shared album.</p>
|
|
</div>
|
|
<div style="padding:32px;border:1px solid ${palette.border};border-top:0;font-size:16px;line-height:1.65">
|
|
${bodyHtml}
|
|
</div>
|
|
<div style="padding:18px 24px;text-align:center;background:${palette.background};border:1px solid ${palette.border};border-top:0">
|
|
<strong style="font-size:18px;color:${palette.ink}">Manyangles</strong>
|
|
<p style="margin:8px 0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:${palette.muted}">Keep the moment.</p>
|
|
<p style="margin:0;font-size:11px;color:${palette.muted}">Hadlock Technologies LLC</p>
|
|
</div>
|
|
</td></tr></table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
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 <photos@manyangles.test>";
|
|
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(`
|
|
<h1 style="margin:0 0 20px;font-size:28px">Verify your email</h1>
|
|
<p>Hi ${escapeHtml(message.name)},</p>
|
|
<p>Confirm this address to finish creating your Manyangles host account.</p>
|
|
<p style="margin:24px 0">
|
|
<a href="${escapeHtml(message.verificationUrl)}"
|
|
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
|
|
Verify email
|
|
</a>
|
|
</p>
|
|
`),
|
|
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(`
|
|
<h1 style="margin:0 0 20px;font-size:28px">Reset your password</h1>
|
|
<p>Hi ${escapeHtml(message.name)},</p>
|
|
<p>Use the secure link below to choose a new password. The link expires in one hour.</p>
|
|
<p style="margin:24px 0">
|
|
<a href="${escapeHtml(message.resetUrl)}"
|
|
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
|
|
Reset password
|
|
</a>
|
|
</p>
|
|
<p style="font-size:12px;color:${palette.muted}">
|
|
If you did not request this, you can ignore this message.
|
|
</p>
|
|
`),
|
|
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(`
|
|
<h1 style="margin:0 0 20px;font-size:28px">You're invited</h1>
|
|
<p>${escapeHtml(message.inviterName)} invited you to help manage photos for an event.</p>
|
|
<p style="margin:24px 0">
|
|
<a href="${escapeHtml(message.inviteUrl)}"
|
|
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
|
|
Accept invite
|
|
</a>
|
|
</p>
|
|
`),
|
|
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 <photos@manyangles.test>",
|
|
attachments: [emailLogoAttachment],
|
|
to: message.to,
|
|
subject: `The gallery for ${message.eventTitle} is ready`,
|
|
html: chrome(`
|
|
<h1 style="margin:0 0 20px;font-size:28px">The gallery is ready</h1>
|
|
<p>Photos from ${escapeHtml(message.eventTitle)} are now available to view.</p>
|
|
<p style="margin:24px 0">
|
|
<a href="${escapeHtml(message.galleryUrl)}"
|
|
style="display:inline-block;padding:13px 20px;border-radius:6px;background:${PRIMARY};color:#fff;text-decoration:none;font-weight:700">
|
|
View gallery
|
|
</a>
|
|
</p>
|
|
`),
|
|
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));
|
|
}
|