Files
Wabenhain/src/lib/email-templates.ts
T
Nick SzaunigandClaude Opus 5 18d1bd28bf fix: Produkt- und Shop-Links in E-Mails ueber dieselbe Domain-Quelle
Nachzug zu d3020cd: email-templates.ts nutzte fuer den Bestaetigungs-Link
bereits getBaseUrl(), fuer den Produkt- und den Shop-Link in derselben
Datei aber weiterhin "https://" + siteConfig.domain. Eine
Wiederverfuegbarkeits-Mail konnte dadurch einen Link auf eine andere Domain
enthalten als die Bestellbestaetigung daneben.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-15 10:01:18 +02:00

377 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatPrice } from "@/lib/format";
import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info";
import { getEmailStrings } from "@/lib/email-strings";
import type { Locale } from "@/i18n/config";
interface EmailContent {
subject: string;
html: string;
text: string;
}
/**
* Gemeinsamer Rahmen fuer alle Transaktions-E-Mails: bewusst mit
* Inline-Styles und Tabellen-Layout statt Tailwind/externem CSS, da viele
* E-Mail-Clients (Outlook, ältere Gmail-Ansichten) beides nicht zuverlaessig
* unterstuetzen.
*/
async function emailLayout(locale: Locale, preheader: string, bodyHtml: string): Promise<string> {
const company = await getCompanyInfo();
return `<!doctype html>
<html lang="${locale}">
<body style="margin:0;padding:0;background-color:#FAF8F3;font-family:Arial,Helvetica,sans-serif;color:#242424;">
<span style="display:none;font-size:1px;color:#FAF8F3;line-height:1px;">${preheader}</span>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#FAF8F3;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" style="max-width:560px;background-color:#ffffff;">
<tr>
<td style="background-color:#24352B;padding:24px 32px;">
<span style="font-size:20px;letter-spacing:2px;color:#F4EFE5;">WABENHAIN</span>
<div style="font-size:10px;letter-spacing:1px;color:#B88A44;margin-top:4px;">
HONIG AUS DEN WESTLICHEN WÄLDERN
</div>
</td>
</tr>
<tr>
<td style="padding:32px;">${bodyHtml}</td>
</tr>
<tr>
<td style="padding:20px 32px;background-color:#F4EFE5;font-size:12px;color:#666;">
${company.name} · ${company.address.street}, ${company.address.zip} ${company.address.city}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function button(url: string, label: string): string {
return `<a href="${url}" style="display:inline-block;margin-top:16px;padding:12px 24px;background-color:#24352B;color:#F4EFE5;text-decoration:none;font-size:14px;letter-spacing:1px;">${label}</a>`;
}
interface OrderConfirmationInput {
locale: Locale;
orderNumber: string;
firstName: string;
fulfillmentMethod: "VERSAND" | "ABHOLUNG";
items: { productName: string; quantity: number; weightGrams: number; totalCents: number }[];
subtotalCents: number;
shippingCents: number;
discountCents?: number;
totalCents: number;
street: string;
zip: string;
city: string;
giftMessage?: string | null;
trackingUrl?: string;
}
export async function orderConfirmationEmail(order: OrderConfirmationInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).orderConfirmation;
const rows = order.items
.map(
(item) =>
`<tr><td style="padding:6px 0;">${item.quantity} × ${item.productName} (${item.weightGrams} g)</td><td style="padding:6px 0;text-align:right;">${formatPrice(item.totalCents, "EUR", order.locale)}</td></tr>`
)
.join("");
const deliveryHtml =
order.fulfillmentMethod === "VERSAND"
? `<p style="margin:4px 0;">${s.deliveryTo}<br>${order.street}<br>${order.zip} ${order.city}</p>`
: `<p style="margin:4px 0;">${s.pickupNote}</p>`;
const giftHtml = order.giftMessage
? `<p style="margin:16px 0 0;padding:12px 16px;background-color:#F4EFE5;font-size:13px;color:#24352B;">
${s.giftNote}
</p>`
: "";
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 20px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
<table role="presentation" width="100%" style="font-size:14px;border-top:1px solid #eee;border-bottom:1px solid #eee;padding:8px 0;">
${rows}
</table>
<table role="presentation" width="100%" style="font-size:14px;margin-top:12px;">
<tr><td>${s.subtotal}</td><td style="text-align:right;">${formatPrice(order.subtotalCents, "EUR", order.locale)}</td></tr>
<tr><td>${s.shipping}</td><td style="text-align:right;">${order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)}</td></tr>
${order.discountCents ? `<tr><td>${s.discount}</td><td style="text-align:right;">−${formatPrice(order.discountCents, "EUR", order.locale)}</td></tr>` : ""}
<tr><td style="font-weight:bold;color:#24352B;padding-top:8px;">${s.total}</td><td style="text-align:right;font-weight:bold;color:#24352B;padding-top:8px;">${formatPrice(order.totalCents, "EUR", order.locale)}</td></tr>
</table>
${deliveryHtml}
${giftHtml}
${order.trackingUrl ? `<div style="margin-top:20px;">${button(order.trackingUrl, s.trackOrder)}</div>` : ""}
`
);
const text = `${s.heading(order.firstName)}\n\n${order.orderNumber}\n\n${order.items
.map((i) => `${i.quantity} x ${i.productName} (${i.weightGrams} g) — ${formatPrice(i.totalCents, "EUR", order.locale)}`)
.join("\n")}\n\n${s.subtotal}: ${formatPrice(order.subtotalCents, "EUR", order.locale)}\n${s.shipping}: ${
order.shippingCents === 0 ? s.freeShipping : formatPrice(order.shippingCents, "EUR", order.locale)
}${order.discountCents ? `\n${s.discount}: −${formatPrice(order.discountCents, "EUR", order.locale)}` : ""}\n${s.total}: ${formatPrice(order.totalCents, "EUR", order.locale)}${
order.giftMessage ? `\n\n${s.giftNote}` : ""
}${order.trackingUrl ? `\n\n${s.trackOrder}: ${order.trackingUrl}` : ""}`;
return { subject: s.subject(order.orderNumber), html, text };
}
export async function newsletterConfirmEmail(locale: Locale, confirmUrl: string): Promise<EmailContent> {
const s = getEmailStrings(locale).newsletterConfirm;
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
${button(confirmUrl, s.button)}
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${s.intro}: ${confirmUrl}`,
};
}
export async function passwordResetEmail(locale: Locale, resetUrl: string): Promise<EmailContent> {
const s = getEmailStrings(locale).passwordReset;
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
${button(resetUrl, s.button)}
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${s.intro} ${resetUrl}`,
};
}
/**
* Sicherheitshinweis-Mail fuer sensible Kontoaenderungen (Passwort geaendert,
* 2FA deaktiviert, Konto geloescht) — unabhaengig davon, ob die Aenderung
* durch die Kundschaft selbst oder potenziell durch jemand anderen ausgeloest
* wurde, damit ein Missbrauch schnellstmoeglich auffaellt.
*/
export type SecurityAlertEvent = "passwordChanged" | "twoFactorDisabled" | "accountDeleted";
export async function securityAlertEmail(locale: Locale, event: SecurityAlertEvent): Promise<EmailContent> {
const s = getEmailStrings(locale).securityAlert;
const eventDescription = s[event];
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${eventDescription}</p>
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.contactNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${eventDescription}\n\n${s.contactNote}`,
};
}
export async function emailVerificationEmail(locale: Locale, verifyUrl: string): Promise<EmailContent> {
const s = getEmailStrings(locale).emailVerification;
const html = await emailLayout(
locale,
s.preheader,
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro}</p>
${button(verifyUrl, s.button)}
<p style="margin:20px 0 0;font-size:12px;color:#888;">${s.ignoreNote}</p>
`
);
return {
subject: s.subject,
html,
text: `${s.intro} ${verifyUrl}`,
};
}
interface OrderStatusUpdateInput {
locale: Locale;
orderNumber: string;
firstName: string;
}
interface ShipmentEmailInput extends OrderStatusUpdateInput {
carrier?: string | null;
trackingNumber?: string | null;
trackingUrl?: string | null;
}
export async function shipmentEmail(order: ShipmentEmailInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).shipment;
const hasTracking = Boolean(order.carrier && order.trackingNumber);
const trackingHtml = hasTracking
? `
<p style="margin:16px 0 0;line-height:1.5;">
${order.carrier}: <strong>${order.trackingNumber}</strong>
</p>
${order.trackingUrl ? button(order.trackingUrl, s.trackButton) : ""}
`
: "";
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
${trackingHtml}
`
);
const trackingText = hasTracking
? `\n\n${order.carrier}: ${order.trackingNumber}${order.trackingUrl ? `\n${order.trackingUrl}` : ""}`
: "";
return {
subject: s.subject(order.orderNumber),
html,
text: `${s.heading(order.firstName)}${trackingText}`,
};
}
export async function pickupReadyEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).pickupReady;
const company = await getCompanyInfo();
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
<p style="margin:16px 0 0;line-height:1.5;">
${company.address.street}<br>${company.address.zip} ${company.address.city}
</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")}\n\n${company.address.street}\n${company.address.zip} ${company.address.city}`,
};
}
/**
* Fuer Zahlungsmethoden mit verzoegerter Bestaetigung (z.B.
* SEPA-Lastschrift), bei denen der Zahlungseinzug im Nachhinein fehlschlaegt
* (checkout.session.async_payment_failed) — die Bestellung wurde bereits
* storniert und der Bestand zurueckgebucht (siehe api/checkout/webhook).
*/
interface BackInStockInput {
locale: Locale;
name: string;
slug: string;
}
export async function backInStockEmail(product: BackInStockInput): Promise<EmailContent> {
const s = getEmailStrings(product.locale).backInStock;
const url = `${getSiteUrl()}/honig/${product.slug}`;
const html = await emailLayout(
product.locale,
s.preheader(product.name),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(product.name)}</p>
${button(url, s.button)}
`
);
return {
subject: s.subject(product.name),
html,
text: `${s.preheader(product.name)} ${url}`,
};
}
export async function returnAcceptedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).returnAccepted;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
};
}
export async function returnRejectedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).returnRejected;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
};
}
export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).returnRefunded;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0;line-height:1.5;">${s.intro(order.orderNumber)}</p>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: s.intro(order.orderNumber).replace(/<\/?strong>/g, ""),
};
}
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).paymentFailed;
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
// einen tatsaechlich anklickbaren Button dazu bliebe das ein reiner
// Lauftext-Hinweis ohne direkten Weg zurueck in den Shop.
const shopUrl = `${getSiteUrl()}/shop`;
const html = await emailLayout(
order.locale,
s.preheader(order.orderNumber),
`
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(order.firstName)}</h1>
<p style="margin:0 0 8px;line-height:1.5;">${s.intro(order.orderNumber)}</p>
<p style="margin:0;line-height:1.5;">${s.retryNote}</p>
<div style="margin-top:20px;">${button(shopUrl, s.button)}</div>
`
);
return {
subject: s.subject(order.orderNumber),
html,
text: `${s.intro(order.orderNumber).replace(/<\/?strong>/g, "")} ${s.retryNote}\n\n${s.button}: ${shopUrl}`,
};
}