Refine workspaces and event publishing; harden uploads and email delivery

This commit is contained in:
2026-09-09 15:44:00 -04:00
parent f5702caaea
commit 574f29a68e
93 changed files with 2885 additions and 535 deletions
+40 -15
View File
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto";
import nodemailer from "nodemailer";
import { Resend } from "resend";
import { EMAIL_LOGO_CID, emailLogoAttachment } from "./logo";
export { emailBrowserPreview } from "./logo";
const PRIMARY = "#8b5a4a";
@@ -16,25 +18,33 @@ function referenceHash(value: string) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function chrome(bodyHtml: string) {
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&amp;family=Geologica:wght@400;600;700&amp;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:#f6f1ea">
<div style="max-width:560px;margin:0 auto;color:#2c2416;background:#fff;font-family:Georgia,serif">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;color:#292524;background:#fff;font-family:'Geologica',Arial,Helvetica,sans-serif"><tr><td>
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
<strong style="font-size:22px;letter-spacing:.04em">Manyangles</strong>
<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:${PRIMARY}">Manyangles</strong></td>
</tr></table>
<p style="margin:8px 0 0;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#78716c">Every angle. One shared album.</p>
</div>
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0">
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0;font-size:16px;line-height:1.65">
${bodyHtml}
</div>
<div style="padding:18px 24px;text-align:center;background:#faf7f2;border:1px solid #e8dfd2;border-top:0">
<strong style="font-size:18px;color:${PRIMARY}">Manyangles</strong>
<p style="margin:8px 0;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:#7a6e5e">Keep the moment.</p>
<p style="margin:0;font-size:11px;color:#7a6e5e">Hadlock Technologies LLC</p>
</div>
</div>
</td></tr></table>
</body>
</html>`;
}
@@ -63,15 +73,21 @@ export function getEmailReadiness() {
};
}
async function sendEmail(input: {
export async function sendEmail(input: {
to: string;
subject: string;
html: string;
text: string;
referenceId: string;
}) {
const provider = process.env.EMAIL_PROVIDER ?? "resend";
const from =
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>";
@@ -87,6 +103,8 @@ async function sendEmail(input: {
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,
@@ -95,14 +113,14 @@ async function sendEmail(input: {
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) {
console.info(`[email:demo] ${input.subject} -> ${input.to}`);
return { id: `demo-${input.referenceId}` };
throw new Error("RESEND_API_KEY is required; use Mailpit for local email");
}
const resend = new Resend(apiKey);
@@ -113,7 +131,8 @@ async function sendEmail(input: {
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 };
}
@@ -192,12 +211,14 @@ export async function sendStaffInviteEmail(message: {
});
}
export async function sendAlbumReadyEmail(message: {
export function renderAlbumReadyEmail(message: {
to: string;
eventTitle: string;
galleryUrl: string;
}) {
return sendEmail({
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(`
@@ -212,5 +233,9 @@ export async function sendAlbumReadyEmail(message: {
`),
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));
}