Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
import { Resend } from "resend";
|
||||
|
||||
const PRIMARY = "#8b5a4a";
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function referenceHash(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
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">
|
||||
</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">
|
||||
<div style="padding:20px 24px;border:1px solid #e8dfd2;border-top:6px solid ${PRIMARY}">
|
||||
<strong style="font-size:22px;letter-spacing:.04em">Vellum</strong>
|
||||
</div>
|
||||
<div style="padding:32px;border:1px solid #e8dfd2;border-top:0">
|
||||
${bodyHtml}
|
||||
</div>
|
||||
<div style="padding:18px 24px;text-align:center;background:#faf7f2;border:1px solid #e8dfd2;border-top:0">
|
||||
<p style="margin:0;font-size:11px;color:#7a6e5e">Hadlock Technologies LLC</p>
|
||||
</div>
|
||||
</div>
|
||||
</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,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendEmail(input: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
referenceId: string;
|
||||
}) {
|
||||
const provider = process.env.EMAIL_PROVIDER ?? "resend";
|
||||
const from =
|
||||
process.env.EMAIL_FROM ??
|
||||
process.env.RESEND_FROM ??
|
||||
"Vellum <photos@vellum.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",
|
||||
});
|
||||
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 },
|
||||
});
|
||||
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}` };
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
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 Vellum 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 Vellum 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 Vellum 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 Vellum 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:#7a6e5e">
|
||||
If you did not request this, you can ignore this message.
|
||||
</p>
|
||||
`),
|
||||
text: `Reset your Vellum 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 Vellum 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 Vellum event: ${message.inviteUrl}`,
|
||||
referenceId: `staff-invite-${referenceHash(message.inviteUrl)}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendAlbumReadyEmail(message: {
|
||||
to: string;
|
||||
eventTitle: string;
|
||||
galleryUrl: string;
|
||||
}) {
|
||||
return sendEmail({
|
||||
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)}`,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user