since3-portal/modules/shared/mail/layout.ts

175 lines
8.7 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 { COMPANY } from "@/modules/shared/data/company"
// Logos hängen als Inline-Bilder an jeder Mail (cid:), damit sie ohne öffentliche URL und auch lokal funktionieren.
export const LOGO_CIDS = { light: "since3-logo-light", dark: "since3-logo-dark" }
// Farben aus dem Portal (Ink-Skala, monochrom) bzw. der Rechnung.
const COLORS = {
page: "#f2f2f2",
card: "#ffffff",
border: "#e3e3e3",
ink: "#0a0a0a",
text: "#1a1a1a",
muted: "#6e6e6e",
panel: "#f7f7f7",
}
// Dark Mode wie im Portal (.dark): Hintergrund ink-950, Flächen ink-900/800, Primary-Button weiß.
const DARK = {
page: "#020202",
card: "#0c0c0c",
border: "#202020",
ink: "#ffffff",
text: "#ededed",
muted: "#949494",
panel: "#202020",
}
// Apple Mail, iOS Mail und Outlook (Mac) werten prefers-color-scheme aus, Outlook.com/-App die [data-ogsc]/[data-ogsb]-Attribute.
// Gmail ignoriert beides und invertiert selbst – dafür hat das helle Logo einen weißen Rand.
const DARK_RULES = `
.bg-page { background: ${DARK.page} !important; }
.bg-card { background: ${DARK.card} !important; border-color: ${DARK.border} !important; }
.bg-panel { background: ${DARK.panel} !important; border-color: ${DARK.border} !important; }
.text-ink { color: ${DARK.ink} !important; }
.text-body { color: ${DARK.text} !important; }
.text-muted { color: ${DARK.muted} !important; }
.btn { background: ${DARK.ink} !important; }
.btn-link { color: ${DARK.page} !important; }
.bar { background: #3a3a3a !important; background-image: linear-gradient(90deg, #3a3a3a 0%, #b9b9b9 100%) !important; }
.logo-light { display: none !important; }
.logo-dark { display: block !important; max-height: none !important; overflow: visible !important; }`
const FONT = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
export function escapeHtml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
export type MailLayout = {
preheader: string
heading: string
// Bereits escaptes HTML (mit escapeHtml gebaut).
paragraphs: string[]
details?: { title?: string; rows: [label: string, value: string][] }
cta?: { label: string; href: string }
closing?: string[]
appUrl: string
}
function paragraph(html: string) {
return `<p class="text-body" style="margin:0 0 16px;font-size:15px;line-height:24px;color:${COLORS.text};">${html}</p>`
}
function detailsPanel({ title, rows }: NonNullable<MailLayout["details"]>) {
const rowsHtml = rows
.map(
([label, value]) => `
<tr>
<td class="text-muted" style="padding:6px 0;font-size:14px;line-height:20px;color:${COLORS.muted};">${escapeHtml(label)}</td>
<td class="text-ink" align="right" style="padding:6px 0 6px 16px;font-size:14px;line-height:20px;color:${COLORS.ink};font-weight:600;">${escapeHtml(value)}</td>
</tr>`
)
.join("")
return `
<table class="bg-panel" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;background:${COLORS.panel};border:1px solid ${COLORS.border};border-radius:10px;">
<tr>
<td style="padding:16px 20px;">
${title ? `<p class="text-ink" style="margin:0 0 6px;font-size:13px;line-height:20px;font-weight:600;color:${COLORS.ink};">${escapeHtml(title)}</p>` : ""}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${rowsHtml}</table>
</td>
</tr>
</table>`
}
function button({ label, href }: NonNullable<MailLayout["cta"]>) {
return `
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px;">
<tr>
<td class="btn" bgcolor="${COLORS.ink}" style="border-radius:8px;">
<a class="btn-link" href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:12px 22px;font-family:${FONT};font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">${escapeHtml(label)}</a>
</td>
</tr>
</table>`
}
export function renderMailLayout(mail: MailLayout) {
const appUrl = mail.appUrl.replace(/\/$/, "")
const closing = mail.closing ?? ["Viele Grüße", "Ihr Since3 Team"]
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>${escapeHtml(mail.heading)}</title>
<style>
:root { color-scheme: light dark; supported-color-schemes: light dark; }
@media (prefers-color-scheme: dark) {${DARK_RULES}
}
${DARK_RULES.trim().split("\n").map((rule) => `[data-ogsc] ${rule.trim()}`).join("\n ")}
@media (max-width: 600px) {
.container { padding: 16px 8px !important; }
.content { padding: 28px 22px !important; }
.header { padding: 24px 22px 0 !important; }
}
</style>
</head>
<body class="bg-page" style="margin:0;padding:0;background:${COLORS.page};font-family:${FONT};-webkit-font-smoothing:antialiased;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${escapeHtml(mail.preheader)}&#8203;&nbsp;&#8203;&nbsp;&#8203;&nbsp;&#8203;&nbsp;&#8203;&nbsp;</div>
<table class="bg-page" role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="${COLORS.page}" style="background:${COLORS.page};">
<tr>
<td align="center" class="container" style="padding:40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;">
<tr>
<td class="bg-card" bgcolor="${COLORS.card}" style="background:${COLORS.card};border:1px solid ${COLORS.border};border-radius:12px;overflow:hidden;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="bar" height="5" bgcolor="#1a1a1a" style="height:5px;line-height:5px;font-size:0;background:#1a1a1a;background-image:linear-gradient(90deg,#000000 0%,#8c8c8c 100%);">&nbsp;</td>
</tr>
<tr>
<td class="header" style="padding:32px 40px 0;">
<a href="${escapeHtml(appUrl)}" target="_blank" style="text-decoration:none;">
<img class="logo-light" src="cid:${LOGO_CIDS.light}" width="144" height="28" alt="Since3" style="display:block;border:0;width:144px;height:auto;">
<!--[if !mso]><!--><div class="logo-dark" style="display:none;max-height:0;overflow:hidden;mso-hide:all;"><img src="cid:${LOGO_CIDS.dark}" width="144" height="28" alt="Since3" style="display:block;border:0;width:144px;height:auto;"></div><!--<![endif]-->
</a>
</td>
</tr>
<tr>
<td class="content" style="padding:32px 40px 36px;">
<h1 class="text-ink" style="margin:0 0 20px;font-size:22px;line-height:30px;font-weight:600;color:${COLORS.ink};">${escapeHtml(mail.heading)}</h1>
${mail.paragraphs.map(paragraph).join("")}
${mail.details ? detailsPanel(mail.details) : ""}
${mail.cta ? button(mail.cta) : ""}
<p class="text-body" style="margin:0;font-size:15px;line-height:24px;color:${COLORS.text};">${closing.map(escapeHtml).join("<br>")}</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="text-muted" style="padding:24px 8px 0;font-size:12px;line-height:18px;color:${COLORS.muted};text-align:center;">
Diese E-Mail wurde automatisch versendet. Bitte antworten Sie nicht auf diese E-Mail.<br>
Bei Fragen erreichen Sie uns unter
<a href="mailto:${COMPANY.email}" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">${COMPANY.email}</a>.<br><br>
${escapeHtml(COMPANY.name)} · ${escapeHtml(COMPANY.street)} · ${escapeHtml(COMPANY.city)}<br>
<a href="${escapeHtml(appUrl)}/impressum" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">Impressum</a>
&nbsp;·&nbsp;
<a href="${escapeHtml(appUrl)}/datenschutz" class="text-muted" style="color:${COLORS.muted};text-decoration:underline;">Datenschutz</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
}