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));
}