45b886e398
Five S-sized items from HANDOFF.md's new-features backlog, all wiring up previously-orphaned infra: - createNotification now sends web push + email for every notification type (follow/comment/reply/reaction/rating/mention), not just comments - Personal recipe notes: private per-user notes on any viewable recipe (recipeNotes table had zero API/UI before this) - Recipe fork/clone: deep-copies a viewable recipe into your own library as a private draft, linked via recipeVariations, respects tier quota - Pantry-aware shopping lists: meal-plan-generated lists now subtract on-hand pantry quantities (ingredientId match, falling back to normalized name match) and flag partial/ambiguous matches instead of guessing - GDPR data export: downloadable JSON of a user's own content and activity across every relevant table, secrets/internal tables excluded New migrations 0025 (unique index for recipe-notes upsert) and 0026 (shopping_list_items.in_pantry) generated, left unapplied like 0023/0024. Verified with typecheck, lint, and a full local `docker build`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
import nodemailer from "nodemailer";
|
|
|
|
function getTransport() {
|
|
const host = process.env["SMTP_HOST"];
|
|
if (!host) return null;
|
|
|
|
return nodemailer.createTransport({
|
|
host,
|
|
port: parseInt(process.env["SMTP_PORT"] ?? "587"),
|
|
secure: process.env["SMTP_SECURE"] === "true",
|
|
auth: {
|
|
user: process.env["SMTP_USER"],
|
|
pass: process.env["SMTP_PASS"],
|
|
},
|
|
});
|
|
}
|
|
|
|
const FROM = process.env["SMTP_FROM"] ?? "Epicure <noreply@epicure.app>";
|
|
|
|
export async function sendEmail({
|
|
to,
|
|
subject,
|
|
html,
|
|
}: {
|
|
to: string;
|
|
subject: string;
|
|
html: string;
|
|
}) {
|
|
const transport = getTransport();
|
|
if (!transport) {
|
|
console.log(`[email:dev] No SMTP_HOST — logging email instead.\n Subject: ${subject}\n To: ${to}\n Body: ${html.replace(/<[^>]+>/g, "").slice(0, 200)}`);
|
|
return;
|
|
}
|
|
try {
|
|
const info = await transport.sendMail({ from: FROM, to, subject, html });
|
|
console.log(`[email:sent] "${subject}" → ${to} | id:${info.messageId} | response:${info.response}`);
|
|
} catch (err) {
|
|
console.error(`[email:error] "${subject}" → ${to} |`, err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ── Templates ─────────────────────────────────────────────────────────────────
|
|
|
|
function layout(title: string, body: string) {
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${title}</title>
|
|
</head>
|
|
<body style="margin:0;padding:0;background:#fafaf9;font-family:Georgia,serif;color:#1c1917;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;">
|
|
<tr><td align="center">
|
|
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e7e5e4;">
|
|
<tr><td style="padding:32px 40px;border-bottom:1px solid #e7e5e4;">
|
|
<span style="font-size:22px;font-weight:bold;letter-spacing:-0.5px;">🍴 Epicure</span>
|
|
</td></tr>
|
|
<tr><td style="padding:40px;">
|
|
${body}
|
|
</td></tr>
|
|
<tr><td style="padding:24px 40px;background:#fafaf9;border-top:1px solid #e7e5e4;">
|
|
<p style="margin:0;font-size:12px;color:#78716c;">You received this email from Epicure. If you didn't request it, ignore this email.</p>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function btn(href: string, label: string) {
|
|
return `<a href="${href}" style="display:inline-block;background:#1c1917;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-size:15px;font-weight:600;margin:24px 0;">${label}</a>`;
|
|
}
|
|
|
|
export function verifyEmailHtml(url: string) {
|
|
return layout(
|
|
"Verify your email — Epicure",
|
|
`<h1 style="margin:0 0 8px;font-size:24px;">Verify your email</h1>
|
|
<p style="margin:0 0 4px;color:#57534e;line-height:1.6;">Click the button below to verify your email address and activate your Epicure account.</p>
|
|
${btn(url, "Verify email")}
|
|
<p style="margin:16px 0 0;font-size:13px;color:#a8a29e;">Link expires in 24 hours. Or paste this URL into your browser:<br/>
|
|
<a href="${url}" style="color:#78716c;font-size:12px;word-break:break-all;">${url}</a></p>`
|
|
);
|
|
}
|
|
|
|
export function resetPasswordHtml(url: string) {
|
|
return layout(
|
|
"Reset your password — Epicure",
|
|
`<h1 style="margin:0 0 8px;font-size:24px;">Reset your password</h1>
|
|
<p style="margin:0 0 4px;color:#57534e;line-height:1.6;">We received a request to reset the password for your Epicure account.</p>
|
|
${btn(url, "Reset password")}
|
|
<p style="margin:16px 0 0;font-size:13px;color:#a8a29e;">Link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.</p>`
|
|
);
|
|
}
|
|
|
|
export function notificationEmailHtml(title: string, body: string, url: string) {
|
|
return layout(
|
|
`${title} — Epicure`,
|
|
`<h1 style="margin:0 0 8px;font-size:24px;">${title}</h1>
|
|
<p style="margin:0 0 4px;color:#57534e;line-height:1.6;">${body}</p>
|
|
${btn(url, "View on Epicure")}`
|
|
);
|
|
}
|
|
|
|
export function welcomeHtml(name: string) {
|
|
return layout(
|
|
"Welcome to Epicure",
|
|
`<h1 style="margin:0 0 8px;font-size:24px;">Welcome, ${name}!</h1>
|
|
<p style="margin:0 0 16px;color:#57534e;line-height:1.6;">Your account is ready. Start by creating your first recipe, importing from a URL, or letting AI generate one for you.</p>
|
|
${btn(process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000", "Go to Epicure")}
|
|
<p style="margin:16px 0 0;font-size:13px;color:#a8a29e;">Questions? Just reply to this email.</p>`
|
|
);
|
|
}
|