feat(admin): /admin/email-templates editor (t-paliad-072)

DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.

Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
  and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
  language file when no DB row, Save validates parse + structural
  invariants and writes a version, Reset deletes the active row, Restore
  copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
  back to the embedded default if the active row is malformed at parse
  time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
  text/template strings stored in the (key, lang) row. Default subjects
  ship with a {{/* keep this phrasing */}} comment pointing at the
  reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
  + .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
  No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
  RequireAdminFunc(users) admin middleware, same shape as /admin/team.

Frontend:
- /admin/email-templates list page — three cards (one per template),
  each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
  textarea + variable docs + actions on the left, sandboxed iframe
  preview + version log on the right. 500 ms debounced live preview;
  save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.

Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.

Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean

Design: docs/design-email-templates-2026-04-29.md.
This commit is contained in:
m
2026-04-29 22:09:39 +02:00
parent c4122bc265
commit 0e3411c40b
30 changed files with 2869 additions and 267 deletions

View File

@@ -111,6 +111,13 @@ func main() {
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
// Wire EmailTemplateService onto the MailService so DB-backed admin
// edits propagate without a process restart. The constructor is split
// from MailService creation because the DB pool isn't available yet
// at the point we build mailSvc above.
emailTemplateSvc := services.NewEmailTemplateService(pool)
mailSvc.SetTemplateService(emailTemplateSvc)
svcBundle = &handlers.Services{
Project: projectSvc,
Team: teamSvc,
@@ -130,6 +137,7 @@ func main() {
Invite: inviteSvc,
Agenda: services.NewAgendaService(pool, users),
Audit: services.NewAuditService(pool),
EmailTemplate: emailTemplateSvc,
}
log.Println("Phase B services initialised")

View File

@@ -32,6 +32,8 @@ import { renderTeam } from "./src/team";
import { renderAdmin } from "./src/admin";
import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
@@ -109,6 +111,8 @@ async function build() {
join(import.meta.dir, "src/client/admin.ts"),
join(import.meta.dir, "src/client/admin-team.ts"),
join(import.meta.dir, "src/client/admin-audit-log.ts"),
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
@@ -214,6 +218,8 @@ async function build() {
await Bun.write(join(DIST, "admin.html"), renderAdmin());
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
await Bun.write(join(DIST, "admin-audit-log.html"), renderAdminAuditLog());
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in

View File

@@ -0,0 +1,117 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/email-templates/{key}?lang=de — full editor. The shell holds the
// chrome and the empty form/preview/variable wells. The client bundle reads
// the key from location.pathname and the lang from location.search, fetches
// the active row + variables + version log in parallel, and populates.
export function renderAdminEmailTemplatesEdit(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.email_templates.editor.title">Email-Template bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/email-templates" />
<BottomNav currentPath="/admin/email-templates" />
<main>
<section className="tool-page">
<div className="container admin-et-edit-container">
<div className="tool-header">
<div>
<a href="/admin/email-templates" className="admin-et-back" data-i18n="admin.email_templates.back">
&larr; Zurück zur Liste
</a>
<h1 id="admin-et-title" data-i18n="admin.email_templates.editor.heading">Email-Template bearbeiten</h1>
<p id="admin-et-subtitle" className="tool-subtitle" />
</div>
<div className="admin-et-lang-toggle" id="admin-et-lang-toggle" role="tablist" aria-label="Language">
<button type="button" className="admin-et-lang-btn" data-lang="de" aria-pressed="true">DE</button>
<button type="button" className="admin-et-lang-btn" data-lang="en" aria-pressed="false">EN</button>
</div>
</div>
<div id="admin-et-feedback" className="form-msg" style="display:none" />
<div className="admin-et-editor">
<div className="admin-et-editor-form">
<div className="form-field" id="admin-et-subject-wrap">
<label htmlFor="admin-et-subject" data-i18n="admin.email_templates.editor.subject">Betreff</label>
<input type="text" id="admin-et-subject" className="admin-et-subject-input" autocomplete="off" />
</div>
<div className="form-field">
<label htmlFor="admin-et-body" data-i18n="admin.email_templates.editor.body">HTML-Body</label>
<textarea id="admin-et-body" className="admin-et-body-input" rows={24} spellcheck={false} />
</div>
<div className="form-field">
<label htmlFor="admin-et-note" data-i18n="admin.email_templates.editor.note_optional">Notiz (optional)</label>
<input type="text" id="admin-et-note" className="admin-et-note-input" autocomplete="off"
placeholder="z.B. Korrektur nach Anwalts-Feedback"
data-i18n-placeholder="admin.email_templates.editor.note_placeholder" />
</div>
<details className="admin-et-variables">
<summary data-i18n="admin.email_templates.editor.variables">Verfügbare Variablen</summary>
<div id="admin-et-variables-list" className="admin-et-variables-list" />
</details>
<div className="form-actions admin-et-actions">
<button type="button" id="admin-et-save" className="btn-primary" disabled
data-i18n="admin.email_templates.editor.save">Speichern</button>
<button type="button" id="admin-et-reset" className="btn-secondary"
data-i18n="admin.email_templates.editor.reset">Auf Standard zur&uuml;cksetzen</button>
</div>
</div>
<div className="admin-et-editor-preview">
<div className="admin-et-preview-header">
<h2 data-i18n="admin.email_templates.editor.preview">Vorschau</h2>
<div className="admin-et-preview-actions">
<select id="admin-et-slot" className="admin-et-slot-select" style="display:none">
<option value="morning" data-i18n="admin.email_templates.editor.slot.morning">Morgen-Slot</option>
<option value="evening" data-i18n="admin.email_templates.editor.slot.evening">Abend-Slot</option>
</select>
<button type="button" id="admin-et-preview-refresh" className="btn-tertiary"
data-i18n="admin.email_templates.editor.preview_refresh">Vorschau aktualisieren</button>
</div>
</div>
<div className="admin-et-preview-subject" id="admin-et-preview-subject" />
<iframe
id="admin-et-preview-frame"
className="admin-et-preview-frame"
sandbox="allow-same-origin"
title="Email preview"
/>
<details className="admin-et-versions">
<summary data-i18n="admin.email_templates.editor.versions">Versionen</summary>
<ul id="admin-et-versions-list" className="admin-et-versions-list" />
</details>
</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-email-templates-edit.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,56 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/email-templates — list of canonical templates with per-language
// edit links. Cards are populated client-side from
// GET /api/admin/email-templates so the static SPA shell stays language-
// neutral and the "Standard" / "Zuletzt geändert" status reflects current
// DB state, not build time.
export function renderAdminEmailTemplates(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.email_templates.title">Email-Templates &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/email-templates" />
<BottomNav currentPath="/admin/email-templates" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.email_templates.heading">Email-Templates</h1>
<p className="tool-subtitle" data-i18n="admin.email_templates.subtitle">
Vorlagen f&uuml;r Einladungen, Erinnerungen und das Layout-Wrapper anpassen.
</p>
</div>
</div>
<div id="admin-et-feedback" className="form-msg" style="display:none" />
<div className="grid grid-2" id="admin-et-list">
<div className="card admin-et-loading" data-i18n="admin.email_templates.loading">Lade&hellip;</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/admin-email-templates.js"></script>
</body>
</html>
);
}

View File

@@ -26,13 +26,6 @@ const PLANNED: PlannedCard[] = [
fallbackTitle: "Dezernate",
fallbackDesc: "Dezernate anlegen und Mitglieder verwalten.",
},
{
icon: ICON_MAIL,
i18nTitle: "admin.card.email_templates.title",
i18nDesc: "admin.card.email_templates.desc",
fallbackTitle: "Email-Templates",
fallbackDesc: "Vorlagen für Einladungen, Erinnerungen und Benachrichtigungen anpassen.",
},
{
icon: ICON_FLAG,
i18nTitle: "admin.card.feature_flags.title",
@@ -81,6 +74,11 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.audit.title">Audit-Log</h2>
<p data-i18n="admin.card.audit.desc">Wer hat wann was ge&auml;ndert? Nachvollziehbarkeit f&uuml;r sicherheitsrelevante Aktionen.</p>
</a>
<a href="/admin/email-templates" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<h2 data-i18n="admin.card.email_templates.title">Email-Templates</h2>
<p data-i18n="admin.card.email_templates.desc">Vorlagen f&uuml;r Einladungen, Erinnerungen und Layout anpassen.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>

View File

@@ -0,0 +1,420 @@
import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
// /admin/email-templates/{key}?lang=de — editor client.
//
// Wires the static SPA shell to the API: fetches active row + variables +
// version log on load, debounces a preview request on every input change,
// posts saves and resets, and offers per-version restore.
//
// Render-only HTML is built with createElement (not innerHTML on user data)
// so a malicious template body can't escape the editor chrome — the
// preview iframe sandboxes the rendered HTML separately.
interface ActiveRow {
key: string;
lang: string;
subject: string;
body: string;
updated_at?: string | null;
is_default: boolean;
}
interface VariableContract {
name: string;
type: string;
description: string;
sample_de: string;
sample_en: string;
}
interface VersionRow {
id: string;
saved_at: string;
saved_by?: string | null;
note: string;
subject: string;
body: string;
}
const PREVIEW_DEBOUNCE_MS = 500;
let currentKey = "";
let currentLang: "de" | "en" = "de";
let activeRow: ActiveRow | null = null;
let variables: VariableContract[] = [];
let dirty = false;
let previewTimer: number | null = null;
function readKeyFromPath(): string {
const m = location.pathname.match(/^\/admin\/email-templates\/([^/]+)/);
return m ? decodeURIComponent(m[1]) : "";
}
function readLangFromQuery(): "de" | "en" {
const params = new URLSearchParams(location.search);
const v = params.get("lang");
return v === "en" ? "en" : "de";
}
function setLangInURL(lang: "de" | "en") {
const url = new URL(location.href);
url.searchParams.set("lang", lang);
history.replaceState(null, "", url.toString());
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-et-feedback");
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
el.style.display = "block";
if (!isError) {
setTimeout(() => {
if (el.textContent === msg) el.style.display = "none";
}, 3000);
}
}
function clearFeedback() {
const el = document.getElementById("admin-et-feedback");
if (el) el.style.display = "none";
}
function setDirty(d: boolean) {
dirty = d;
const saveBtn = document.getElementById("admin-et-save") as HTMLButtonElement | null;
if (saveBtn) saveBtn.disabled = !d;
}
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString();
}
function applyToInputs(row: ActiveRow) {
const subj = document.getElementById("admin-et-subject") as HTMLInputElement | null;
const body = document.getElementById("admin-et-body") as HTMLTextAreaElement | null;
const subjWrap = document.getElementById("admin-et-subject-wrap");
if (subj) subj.value = row.subject;
if (body) body.value = row.body;
if (subjWrap) {
// base templates have no subject of their own — hide the field.
subjWrap.style.display = row.key === "base" ? "none" : "";
}
const slot = document.getElementById("admin-et-slot") as HTMLSelectElement | null;
if (slot) {
slot.style.display = row.key === "deadline_digest" ? "" : "none";
}
setDirty(false);
const sub = document.getElementById("admin-et-subtitle");
if (sub) {
if (row.is_default) {
sub.textContent = t("admin.email_templates.editor.is_default") || "Aktuell wird der Standard verwendet.";
} else {
const tpl = t("admin.email_templates.editor.last_modified") || "Zuletzt geändert: {date}";
sub.textContent = tpl.replace("{date}", fmtDate(row.updated_at));
}
}
}
function applyTitle(key: string, lang: string) {
const title = document.getElementById("admin-et-title");
if (!title) return;
const tpl = t("admin.email_templates.editor.heading_for") ||
"{title} — {lang}";
const langName = lang === "en"
? (t("admin.email_templates.lang.en") || "Englisch")
: (t("admin.email_templates.lang.de") || "Deutsch");
title.textContent = tpl
.replace("{title}", t(`admin.email_templates.card.${key}.title`) || key)
.replace("{lang}", langName);
}
function applyLangToggle(lang: "de" | "en") {
document.querySelectorAll(".admin-et-lang-btn").forEach((el) => {
const btn = el as HTMLButtonElement;
const isActive = btn.dataset.lang === lang;
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
btn.classList.toggle("active", isActive);
});
}
function renderVariables() {
const list = document.getElementById("admin-et-variables-list");
if (!list) return;
list.innerHTML = "";
for (const v of variables) {
const row = document.createElement("div");
row.className = "admin-et-variable-row";
const name = document.createElement("code");
name.className = "admin-et-variable-name";
name.textContent = v.name;
const type = document.createElement("span");
type.className = "admin-et-variable-type";
type.textContent = v.type;
const desc = document.createElement("span");
desc.className = "admin-et-variable-desc";
desc.textContent = v.description;
const sample = document.createElement("span");
sample.className = "admin-et-variable-sample";
sample.textContent = "→ " + (currentLang === "en" ? v.sample_en : v.sample_de);
row.appendChild(name);
row.appendChild(type);
row.appendChild(desc);
row.appendChild(sample);
list.appendChild(row);
}
}
function renderVersions(rows: VersionRow[]) {
const list = document.getElementById("admin-et-versions-list");
if (!list) return;
list.innerHTML = "";
if (rows.length === 0) {
const empty = document.createElement("li");
empty.className = "admin-et-version-empty";
empty.textContent = t("admin.email_templates.editor.versions_empty") || "Keine Versionen.";
list.appendChild(empty);
return;
}
for (const v of rows) {
const li = document.createElement("li");
li.className = "admin-et-version-row";
const date = document.createElement("span");
date.className = "admin-et-version-date";
date.textContent = fmtDate(v.saved_at);
const note = document.createElement("span");
note.className = "admin-et-version-note";
note.textContent = v.note || "";
const restore = document.createElement("button");
restore.type = "button";
restore.className = "btn-tertiary admin-et-version-restore";
restore.textContent = t("admin.email_templates.editor.restore") || "Wiederherstellen";
restore.dataset.versionId = v.id;
restore.addEventListener("click", () => onRestoreClick(v.id));
li.appendChild(date);
li.appendChild(note);
li.appendChild(restore);
list.appendChild(li);
}
}
async function loadActive() {
applyTitle(currentKey, currentLang);
applyLangToggle(currentLang);
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}`);
if (resp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (!resp.ok) {
showFeedback(t("admin.email_templates.load_error") || "Fehler beim Laden.", true);
return;
}
activeRow = (await resp.json()) as ActiveRow;
applyToInputs(activeRow);
void schedulePreview();
}
async function loadVariables() {
const resp = await fetch(`/api/admin/email-templates/${currentKey}/variables`);
if (!resp.ok) {
variables = [];
renderVariables();
return;
}
variables = (await resp.json()) as VariableContract[];
renderVariables();
}
async function loadVersions() {
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}/versions`);
if (!resp.ok) {
renderVersions([]);
return;
}
const rows = (await resp.json()) as VersionRow[];
renderVersions(rows);
}
async function refreshPreview() {
const subj = (document.getElementById("admin-et-subject") as HTMLInputElement | null)?.value || "";
const body = (document.getElementById("admin-et-body") as HTMLTextAreaElement | null)?.value || "";
const slotEl = document.getElementById("admin-et-slot") as HTMLSelectElement | null;
const slot = currentKey === "deadline_digest" && slotEl ? slotEl.value : "";
const resp = await fetch(
`/api/admin/email-templates/${currentKey}/${currentLang}/preview`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject: subj, body, slot }),
},
);
const subjEl = document.getElementById("admin-et-preview-subject");
const frame = document.getElementById("admin-et-preview-frame") as HTMLIFrameElement | null;
if (resp.status === 422) {
const err = (await resp.json().catch(() => ({ error: "" }))) as { error?: string };
if (subjEl) subjEl.textContent = "";
if (frame) frame.srcdoc = "";
showFeedback(
(t("admin.email_templates.editor.parse_error") || "Template-Fehler:") + " " + (err.error || ""),
true,
);
return;
}
if (!resp.ok) {
showFeedback(t("admin.email_templates.editor.preview_error") || "Vorschau fehlgeschlagen.", true);
return;
}
clearFeedback();
const data = (await resp.json()) as { subject_rendered: string; html_rendered: string };
if (subjEl) subjEl.textContent = data.subject_rendered;
if (frame) frame.srcdoc = data.html_rendered;
}
function schedulePreview() {
if (previewTimer !== null) {
clearTimeout(previewTimer);
}
previewTimer = window.setTimeout(() => {
previewTimer = null;
void refreshPreview();
}, PREVIEW_DEBOUNCE_MS);
}
async function onSaveClick() {
if (!activeRow) return;
const subj = (document.getElementById("admin-et-subject") as HTMLInputElement | null)?.value || "";
const body = (document.getElementById("admin-et-body") as HTMLTextAreaElement | null)?.value || "";
const note = (document.getElementById("admin-et-note") as HTMLInputElement | null)?.value || "";
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject: subj, body, note }),
});
if (resp.status === 422) {
const err = (await resp.json().catch(() => ({ error: "" }))) as { error?: string };
showFeedback(
(t("admin.email_templates.editor.parse_error") || "Template-Fehler:") + " " + (err.error || ""),
true,
);
return;
}
if (!resp.ok) {
const err = (await resp.json().catch(() => ({ error: resp.statusText }))) as { error?: string };
showFeedback((t("admin.email_templates.editor.save_error") || "Speichern fehlgeschlagen.") + " " + (err.error || ""), true);
return;
}
showFeedback(t("admin.email_templates.editor.save_ok") || "Gespeichert.", false);
const noteEl = document.getElementById("admin-et-note") as HTMLInputElement | null;
if (noteEl) noteEl.value = "";
void loadActive();
void loadVersions();
}
async function onResetClick() {
if (!confirm(t("admin.email_templates.editor.reset_confirm") || "Wirklich auf den Standard zurücksetzen?")) {
return;
}
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}/reset`, {
method: "POST",
});
if (!resp.ok) {
showFeedback(t("admin.email_templates.editor.reset_error") || "Zurücksetzen fehlgeschlagen.", true);
return;
}
showFeedback(t("admin.email_templates.editor.reset_ok") || "Auf Standard zurückgesetzt.", false);
void loadActive();
void loadVersions();
}
async function onRestoreClick(versionID: string) {
if (!confirm(t("admin.email_templates.editor.restore_confirm") || "Diese Version wiederherstellen?")) {
return;
}
const resp = await fetch(
`/api/admin/email-templates/${currentKey}/${currentLang}/restore/${versionID}`,
{ method: "POST" },
);
if (!resp.ok) {
showFeedback(t("admin.email_templates.editor.restore_error") || "Wiederherstellen fehlgeschlagen.", true);
return;
}
showFeedback(t("admin.email_templates.editor.restore_ok") || "Version wiederhergestellt.", false);
void loadActive();
void loadVersions();
}
function onLangButton(lang: "de" | "en") {
if (lang === currentLang) return;
if (dirty && !confirm(t("admin.email_templates.editor.dirty_warn") || "Ungespeicherte Änderungen verwerfen?")) {
return;
}
currentLang = lang;
setLangInURL(lang);
applyTitle(currentKey, lang);
applyLangToggle(lang);
renderVariables();
void loadActive();
void loadVersions();
}
function wireInputs() {
const onAnyChange = () => {
setDirty(true);
schedulePreview();
};
document.getElementById("admin-et-subject")?.addEventListener("input", onAnyChange);
document.getElementById("admin-et-body")?.addEventListener("input", onAnyChange);
document.getElementById("admin-et-slot")?.addEventListener("change", () => {
void refreshPreview();
});
document.getElementById("admin-et-save")?.addEventListener("click", () => {
void onSaveClick();
});
document.getElementById("admin-et-reset")?.addEventListener("click", () => {
void onResetClick();
});
document.getElementById("admin-et-preview-refresh")?.addEventListener("click", () => {
void refreshPreview();
});
document.querySelectorAll(".admin-et-lang-btn").forEach((el) => {
const btn = el as HTMLButtonElement;
btn.addEventListener("click", () => {
const lang = btn.dataset.lang as "de" | "en";
onLangButton(lang);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
currentKey = readKeyFromPath();
currentLang = readLangFromQuery();
if (!currentKey) {
showFeedback(t("admin.email_templates.editor.unknown_key") || "Unbekannter Template-Schlüssel.", true);
return;
}
wireInputs();
applyTitle(currentKey, currentLang);
applyLangToggle(currentLang);
void loadActive();
void loadVariables();
void loadVersions();
onLangChange(() => {
applyTitle(currentKey, currentLang);
renderVariables();
});
});

View File

@@ -0,0 +1,150 @@
import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
// /admin/email-templates list page. Fetches per-(key, lang) summaries and
// renders them as cards grouped by key. Each card shows the human title plus
// two language buttons that link into the editor.
interface Summary {
key: string;
lang: string;
is_default: boolean;
updated_at?: string | null;
}
interface CardCopy {
title_key: string;
desc_key: string;
fallback_title: string;
fallback_desc: string;
}
const CARDS: Record<string, CardCopy> = {
invitation: {
title_key: "admin.email_templates.card.invitation.title",
desc_key: "admin.email_templates.card.invitation.desc",
fallback_title: "Einladung",
fallback_desc:
"E-Mail an neue Kolleg:innen, ausgelöst über die Sidebar.",
},
deadline_digest: {
title_key: "admin.email_templates.card.deadline_digest.title",
desc_key: "admin.email_templates.card.deadline_digest.desc",
fallback_title: "Fristen-Sammelmail",
fallback_desc:
"Tägliche Morgen- und Abend-Mail mit überfälligen, heute fälligen und kommenden Fristen.",
},
base: {
title_key: "admin.email_templates.card.base.title",
desc_key: "admin.email_templates.card.base.desc",
fallback_title: "Layout-Wrapper",
fallback_desc:
"Geteilter HTML-Rahmen mit Header und Footer, der alle E-Mails umschliesst.",
},
};
const KEY_ORDER = ["invitation", "deadline_digest", "base"];
function fmtDate(iso?: string | null): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString();
}
function statusLabel(s: Summary): string {
if (s.is_default) {
return t("admin.email_templates.status.default") || "Standard";
}
const date = fmtDate(s.updated_at);
const tpl = t("admin.email_templates.status.last_modified") || "Zuletzt geändert: {date}";
return tpl.replace("{date}", date);
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-et-feedback");
if (!el) return;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
el.style.display = "block";
}
function render(summaries: Summary[]) {
const container = document.getElementById("admin-et-list");
if (!container) return;
// Group by key in canonical order.
const byKey: Record<string, Summary[]> = {};
for (const s of summaries) {
(byKey[s.key] ||= []).push(s);
}
container.innerHTML = "";
for (const key of KEY_ORDER) {
const meta = CARDS[key];
const rows = byKey[key] || [];
const card = document.createElement("div");
card.className = "card admin-et-card";
const title = t(meta.title_key) || meta.fallback_title;
const desc = t(meta.desc_key) || meta.fallback_desc;
const header = document.createElement("div");
header.className = "admin-et-card-header";
const h2 = document.createElement("h2");
h2.textContent = title;
const code = document.createElement("code");
code.className = "admin-et-card-key";
code.textContent = key;
header.appendChild(h2);
header.appendChild(code);
card.appendChild(header);
const p = document.createElement("p");
p.textContent = desc;
card.appendChild(p);
const langs = document.createElement("div");
langs.className = "admin-et-card-langs";
for (const lang of ["de", "en"]) {
const s = rows.find((r) => r.lang === lang);
const a = document.createElement("a");
a.className = "admin-et-card-lang-btn";
a.href = `/admin/email-templates/${key}?lang=${lang}`;
const flag = document.createElement("span");
flag.className = "admin-et-card-lang-flag";
flag.textContent = lang.toUpperCase();
const status = document.createElement("span");
status.className = "admin-et-card-lang-status";
status.textContent = s ? statusLabel(s) : t("admin.email_templates.status.default") || "Standard";
a.appendChild(flag);
a.appendChild(status);
langs.appendChild(a);
}
card.appendChild(langs);
container.appendChild(card);
}
}
async function loadAndRender() {
const resp = await fetch("/api/admin/email-templates");
if (resp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (!resp.ok) {
showFeedback(t("admin.email_templates.load_error") || "Fehler beim Laden.", true);
return;
}
const summaries = (await resp.json()) as Summary[];
render(summaries);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
void loadAndRender();
onLangChange(() => {
void loadAndRender();
});
});

View File

@@ -1248,9 +1248,56 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.audit.title": "Audit-Log",
"admin.card.audit.desc": "Wer hat wann was geändert? Nachvollziehbarkeit für sicherheitsrelevante Aktionen.",
"admin.card.email_templates.title": "Email-Templates",
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Benachrichtigungen anpassen.",
"admin.card.email_templates.desc": "Vorlagen für Einladungen, Erinnerungen und Layout anpassen.",
"admin.card.feature_flags.title": "Feature-Flags",
"admin.card.feature_flags.desc": "Funktionen pro Standort, Dezernat oder Rolle aktivieren.",
"admin.email_templates.title": "Email-Templates — Paliad",
"admin.email_templates.heading": "Email-Templates",
"admin.email_templates.subtitle": "Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.",
"admin.email_templates.loading": "Lade…",
"admin.email_templates.load_error": "Templates konnten nicht geladen werden.",
"admin.email_templates.back": "← Zurück zur Liste",
"admin.email_templates.lang.de": "Deutsch",
"admin.email_templates.lang.en": "Englisch",
"admin.email_templates.status.default": "Standard",
"admin.email_templates.status.last_modified": "Zuletzt geändert: {date}",
"admin.email_templates.card.invitation.title": "Einladung",
"admin.email_templates.card.invitation.desc": "E-Mail an neue Kolleg:innen, ausgelöst über die Sidebar.",
"admin.email_templates.card.deadline_digest.title": "Fristen-Sammelmail",
"admin.email_templates.card.deadline_digest.desc": "Tägliche Morgen- und Abend-Mail mit überfälligen, heute fälligen und kommenden Fristen.",
"admin.email_templates.card.base.title": "Layout-Wrapper",
"admin.email_templates.card.base.desc": "Geteilter HTML-Rahmen mit Header und Footer, der alle E-Mails umschliesst.",
"admin.email_templates.editor.title": "Email-Template bearbeiten — Paliad",
"admin.email_templates.editor.heading": "Email-Template bearbeiten",
"admin.email_templates.editor.heading_for": "{title} — {lang}",
"admin.email_templates.editor.is_default": "Aktuell wird der Standard verwendet.",
"admin.email_templates.editor.last_modified": "Zuletzt geändert: {date}",
"admin.email_templates.editor.subject": "Betreff",
"admin.email_templates.editor.body": "HTML-Body",
"admin.email_templates.editor.note_optional": "Notiz (optional)",
"admin.email_templates.editor.note_placeholder": "z.B. Korrektur nach Anwalts-Feedback",
"admin.email_templates.editor.variables": "Verfügbare Variablen",
"admin.email_templates.editor.preview": "Vorschau",
"admin.email_templates.editor.preview_refresh": "Vorschau aktualisieren",
"admin.email_templates.editor.preview_error": "Vorschau fehlgeschlagen.",
"admin.email_templates.editor.parse_error": "Template-Fehler:",
"admin.email_templates.editor.save": "Speichern",
"admin.email_templates.editor.save_ok": "Gespeichert.",
"admin.email_templates.editor.save_error": "Speichern fehlgeschlagen.",
"admin.email_templates.editor.reset": "Auf Standard zurücksetzen",
"admin.email_templates.editor.reset_confirm": "Wirklich auf den Standard zurücksetzen?",
"admin.email_templates.editor.reset_ok": "Auf Standard zurückgesetzt.",
"admin.email_templates.editor.reset_error": "Zurücksetzen fehlgeschlagen.",
"admin.email_templates.editor.versions": "Versionen",
"admin.email_templates.editor.versions_empty": "Keine Versionen.",
"admin.email_templates.editor.restore": "Wiederherstellen",
"admin.email_templates.editor.restore_confirm": "Diese Version wiederherstellen?",
"admin.email_templates.editor.restore_ok": "Version wiederhergestellt.",
"admin.email_templates.editor.restore_error": "Wiederherstellen fehlgeschlagen.",
"admin.email_templates.editor.dirty_warn": "Ungespeicherte Änderungen verwerfen?",
"admin.email_templates.editor.unknown_key": "Unbekannter Template-Schlüssel.",
"admin.email_templates.editor.slot.morning": "Morgen-Slot",
"admin.email_templates.editor.slot.evening": "Abend-Slot",
"admin.team.title": "Team-Verwaltung — Paliad",
"admin.team.heading": "Team-Verwaltung",
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
@@ -2571,9 +2618,56 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.card.audit.title": "Audit Log",
"admin.card.audit.desc": "Who changed what, and when. Traceability for security-relevant actions.",
"admin.card.email_templates.title": "Email Templates",
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and notifications.",
"admin.card.email_templates.desc": "Customise templates for invitations, reminders and the wrapper layout.",
"admin.card.feature_flags.title": "Feature Flags",
"admin.card.feature_flags.desc": "Enable features per office, department or role.",
"admin.email_templates.title": "Email Templates — Paliad",
"admin.email_templates.heading": "Email Templates",
"admin.email_templates.subtitle": "Customise templates for invitations, reminders, and the shared layout wrapper.",
"admin.email_templates.loading": "Loading…",
"admin.email_templates.load_error": "Failed to load templates.",
"admin.email_templates.back": "← Back to list",
"admin.email_templates.lang.de": "German",
"admin.email_templates.lang.en": "English",
"admin.email_templates.status.default": "Default",
"admin.email_templates.status.last_modified": "Last modified: {date}",
"admin.email_templates.card.invitation.title": "Invitation",
"admin.email_templates.card.invitation.desc": "Email sent to new colleagues from the sidebar invite flow.",
"admin.email_templates.card.deadline_digest.title": "Deadline digest",
"admin.email_templates.card.deadline_digest.desc": "Daily morning + evening email with overdue, due-today, and upcoming deadlines.",
"admin.email_templates.card.base.title": "Layout wrapper",
"admin.email_templates.card.base.desc": "Shared HTML frame (header + footer) that wraps every email.",
"admin.email_templates.editor.title": "Edit email template — Paliad",
"admin.email_templates.editor.heading": "Edit email template",
"admin.email_templates.editor.heading_for": "{title} — {lang}",
"admin.email_templates.editor.is_default": "Currently using the default.",
"admin.email_templates.editor.last_modified": "Last modified: {date}",
"admin.email_templates.editor.subject": "Subject",
"admin.email_templates.editor.body": "HTML body",
"admin.email_templates.editor.note_optional": "Note (optional)",
"admin.email_templates.editor.note_placeholder": "e.g. Correction following counsel feedback",
"admin.email_templates.editor.variables": "Available variables",
"admin.email_templates.editor.preview": "Preview",
"admin.email_templates.editor.preview_refresh": "Refresh preview",
"admin.email_templates.editor.preview_error": "Preview failed.",
"admin.email_templates.editor.parse_error": "Template error:",
"admin.email_templates.editor.save": "Save",
"admin.email_templates.editor.save_ok": "Saved.",
"admin.email_templates.editor.save_error": "Save failed.",
"admin.email_templates.editor.reset": "Reset to default",
"admin.email_templates.editor.reset_confirm": "Really reset to default?",
"admin.email_templates.editor.reset_ok": "Reset to default.",
"admin.email_templates.editor.reset_error": "Reset failed.",
"admin.email_templates.editor.versions": "Versions",
"admin.email_templates.editor.versions_empty": "No versions yet.",
"admin.email_templates.editor.restore": "Restore",
"admin.email_templates.editor.restore_confirm": "Restore this version?",
"admin.email_templates.editor.restore_ok": "Version restored.",
"admin.email_templates.editor.restore_error": "Restore failed.",
"admin.email_templates.editor.dirty_warn": "Discard unsaved changes?",
"admin.email_templates.editor.unknown_key": "Unknown template key.",
"admin.email_templates.editor.slot.morning": "Morning slot",
"admin.email_templates.editor.slot.evening": "Evening slot",
"admin.team.title": "Team Management — Paliad",
"admin.team.heading": "Team Management",
"admin.team.subtitle": "View, edit and add Paliad accounts.",

View File

@@ -7373,3 +7373,303 @@ dialog.quick-add-sheet::backdrop {
flex-wrap: wrap;
}
}
/* /admin/email-templates — list + editor (t-paliad-072) */
.admin-et-loading {
color: var(--color-text-muted);
font-style: italic;
text-align: center;
}
.admin-et-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.admin-et-card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.admin-et-card-header h2 {
margin: 0;
font-size: 1.1rem;
}
.admin-et-card-key {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-muted);
padding: 2px 6px;
border-radius: 4px;
}
.admin-et-card-langs {
display: flex;
gap: 0.5rem;
margin-top: auto;
}
.admin-et-card-lang-btn {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
text-decoration: none;
color: inherit;
transition: border-color 120ms, background 120ms;
}
.admin-et-card-lang-btn:hover {
border-color: var(--hlc-lime, #BFF355);
background: var(--color-bg-muted);
}
.admin-et-card-lang-flag {
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.04em;
}
.admin-et-card-lang-status {
font-size: 0.78rem;
color: var(--color-text-muted);
}
/* Editor layout */
.admin-et-edit-container {
max-width: 1400px;
}
.admin-et-back {
display: inline-block;
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.admin-et-back:hover {
color: var(--color-text);
text-decoration: underline;
}
.admin-et-lang-toggle {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
}
.admin-et-lang-btn {
padding: 0.4rem 0.9rem;
border: 0;
background: var(--color-bg);
cursor: pointer;
font-weight: 600;
color: var(--color-text-muted);
}
.admin-et-lang-btn[aria-pressed="true"],
.admin-et-lang-btn.active {
background: var(--hlc-lime, #BFF355);
color: #1c1917;
}
.admin-et-editor {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1.5rem;
margin-top: 1rem;
}
.admin-et-editor-form,
.admin-et-editor-preview {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.admin-et-subject-input,
.admin-et-note-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-size: 0.95rem;
box-sizing: border-box;
}
.admin-et-body-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.85rem;
line-height: 1.5;
box-sizing: border-box;
resize: vertical;
min-height: 320px;
}
.admin-et-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-et-variables {
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.75rem;
}
.admin-et-variables summary {
cursor: pointer;
font-weight: 600;
}
.admin-et-variables-list {
display: grid;
grid-template-columns: max-content max-content 1fr max-content;
gap: 0.4rem 0.75rem;
margin-top: 0.75rem;
font-size: 0.85rem;
}
.admin-et-variable-name {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
color: #1c1917;
}
.admin-et-variable-type {
font-size: 0.75rem;
color: var(--color-text-muted);
background: var(--color-bg-muted);
padding: 1px 6px;
border-radius: 3px;
}
.admin-et-variable-desc {
color: var(--color-text);
}
.admin-et-variable-sample {
color: var(--color-text-muted);
font-style: italic;
text-align: right;
}
.admin-et-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.admin-et-preview-header h2 {
margin: 0;
font-size: 1rem;
}
.admin-et-preview-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.admin-et-slot-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
font-size: 0.85rem;
}
.admin-et-preview-subject {
padding: 0.5rem 0.75rem;
background: var(--color-bg-muted);
border-radius: var(--radius);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.85rem;
word-break: break-word;
}
.admin-et-preview-frame {
width: 100%;
height: 600px;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #f5f5f4;
}
.admin-et-versions {
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.75rem;
}
.admin-et-versions summary {
cursor: pointer;
font-weight: 600;
}
.admin-et-versions-list {
list-style: none;
margin: 0.75rem 0 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.admin-et-version-row {
display: grid;
grid-template-columns: max-content 1fr max-content;
gap: 0.5rem;
align-items: center;
font-size: 0.85rem;
padding: 0.4rem 0.5rem;
border-radius: 4px;
}
.admin-et-version-row:hover {
background: var(--color-bg-muted);
}
.admin-et-version-date {
color: var(--color-text);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.admin-et-version-note {
color: var(--color-text-muted);
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-et-version-empty {
color: var(--color-text-muted);
font-style: italic;
}
@media (max-width: 1024px) {
.admin-et-editor {
grid-template-columns: 1fr;
}
.admin-et-preview-frame {
height: 480px;
}
}

View File

@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS paliad.email_template_versions;
DROP TABLE IF EXISTS paliad.email_templates;

View File

@@ -0,0 +1,46 @@
-- Admin Email-Templates editor (t-paliad-072).
--
-- Two tables backing the /admin/email-templates UI:
-- * paliad.email_templates — exactly one active row per (key, lang) pair.
-- Absence of a row means "use the embedded default shipped with the
-- binary". Saves UPSERT into this table; resets DELETE the row.
-- * paliad.email_template_versions — append-only history. One row per
-- save (and per reset / restore). The service GCs to the most recent
-- 20 rows per (key, lang) inside the same tx as the save, so this table
-- stays at most 3 templates × 2 languages × 20 = 120 rows steady-state.
--
-- RLS enabled with no policies: same shape as paliad.invitations and
-- paliad.reminder_log. The Go server bypasses RLS via its direct DB pool;
-- this denies all PostgREST access (no PostgREST surface today, but kept
-- closed by default).
--
-- key is one of: 'invitation', 'deadline_digest', 'base'. Not enforced via
-- CHECK because the canonical key set lives in code (internal/services/
-- email_template_service.go) and changing it shouldn't require a migration.
CREATE TABLE paliad.email_templates (
key text NOT NULL,
lang text NOT NULL CHECK (lang IN ('de', 'en')),
subject text NOT NULL DEFAULT '',
body text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
PRIMARY KEY (key, lang)
);
CREATE TABLE paliad.email_template_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key text NOT NULL,
lang text NOT NULL CHECK (lang IN ('de', 'en')),
subject text NOT NULL DEFAULT '',
body text NOT NULL,
saved_at timestamptz NOT NULL DEFAULT now(),
saved_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
note text NOT NULL DEFAULT ''
);
CREATE INDEX email_template_versions_key_lang_idx
ON paliad.email_template_versions (key, lang, saved_at DESC);
ALTER TABLE paliad.email_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.email_template_versions ENABLE ROW LEVEL SECURITY;

View File

@@ -0,0 +1,269 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/patholo/internal/services"
)
// email_templates.go — backing endpoints for /admin/email-templates
// (t-paliad-072). Routes are registered behind RequireAdminFunc in
// handlers.go, so handlers assume the caller is global_admin and only the
// operation itself needs validation.
//
// All routes require DATABASE_URL — the editor only makes sense when there's
// somewhere to persist saves. Reads still serve embedded defaults via
// EmailTemplateService.GetActive when no DB row exists, but the editor as a
// surface is gated by requireDB just like /admin/team.
// emailTemplateSummary is one row in the list-templates response. Each (key,
// lang) pair gets its own summary so the editor's three-card list can show
// "Standard" or "Zuletzt geändert: <date>" per language.
type emailTemplateSummary struct {
Key string `json:"key"`
Lang string `json:"lang"`
IsDefault bool `json:"is_default"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
UpdatedBy *uuid.UUID `json:"updated_by,omitempty"`
}
// GET /api/admin/email-templates — summaries for every (canonical key × lang)
// pair, in canonical order. Used by the list page to render the per-template
// cards.
func handleAdminListEmailTemplates(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
out := make([]emailTemplateSummary, 0, len(services.CanonicalEmailTemplateKeys)*len(services.EmailTemplateLanguages))
for _, key := range services.CanonicalEmailTemplateKeys {
for _, lang := range services.EmailTemplateLanguages {
row, err := dbSvc.emailTemplate.GetActive(r.Context(), key, lang)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
out = append(out, emailTemplateSummary{
Key: key,
Lang: lang,
IsDefault: row.IsDefault,
UpdatedAt: row.UpdatedAt,
UpdatedBy: row.UpdatedBy,
})
}
}
writeJSON(w, http.StatusOK, out)
}
// GET /api/admin/email-templates/{key}/{lang} — full active row (subject +
// body + IsDefault + updated_at). Editor uses this to populate the form.
func handleAdminGetEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
key := r.PathValue("key")
lang := r.PathValue("lang")
row, err := dbSvc.emailTemplate.GetActive(r.Context(), key, lang)
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// GET /api/admin/email-templates/{key}/variables — the variable contract for
// a key. Lang-agnostic (sample fields for both languages are in the payload).
func handleAdminEmailTemplateVariables(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
key := r.PathValue("key")
if !services.IsCanonicalKey(key) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": services.ErrTemplateUnknownKey.Error()})
return
}
writeJSON(w, http.StatusOK, services.EmailTemplateVariables(key))
}
// previewRequest is the JSON shape for POST .../{key}/{lang}/preview.
type previewRequest struct {
Subject string `json:"subject"`
Body string `json:"body"`
// Slot is honoured for deadline_digest only ("morning" or "evening").
Slot string `json:"slot,omitempty"`
}
type previewResponse struct {
Subject string `json:"subject_rendered"`
HTML string `json:"html_rendered"`
}
// POST /api/admin/email-templates/{key}/{lang}/preview — render proposed
// subject + body against sample data, no persistence. 422 on parse error so
// the editor can surface the message inline.
func handleAdminPreviewEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
key := r.PathValue("key")
lang := r.PathValue("lang")
if !services.IsCanonicalKey(key) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": services.ErrTemplateUnknownKey.Error()})
return
}
if lang != "de" && lang != "en" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": services.ErrTemplateUnknownLang.Error()})
return
}
var in previewRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if err := services.ValidateTemplate(key, in.Subject, in.Body); err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
sample := services.EmailTemplateSampleData(key, lang, in.Slot)
subj, html, err := dbSvc.mail.RenderPreview(key, lang, in.Subject, in.Body, sample)
if err != nil {
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, previewResponse{Subject: subj, HTML: html})
}
// saveRequest is the JSON shape for PUT .../{key}/{lang}.
type saveRequest struct {
Subject string `json:"subject"`
Body string `json:"body"`
Note string `json:"note,omitempty"`
}
// PUT /api/admin/email-templates/{key}/{lang} — validate, upsert active row,
// append a version. Returns the new version row (incl. id). 422 on bad
// templates surfaces inline in the editor.
func handleAdminSaveEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
key := r.PathValue("key")
lang := r.PathValue("lang")
var in saveRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
ver, err := dbSvc.emailTemplate.Save(r.Context(), services.SaveInput{
Key: key,
Lang: lang,
Subject: in.Subject,
Body: in.Body,
Note: in.Note,
SavedBy: uid,
})
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, ver)
}
// POST /api/admin/email-templates/{key}/{lang}/reset — drop the active row
// and append a 'reset' version. Subsequent renders fall through to the
// embedded default.
func handleAdminResetEmailTemplate(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
ver, err := dbSvc.emailTemplate.Reset(r.Context(), r.PathValue("key"), r.PathValue("lang"), uid)
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, ver)
}
// GET /api/admin/email-templates/{key}/{lang}/versions — most-recent-first
// list, capped at EmailTemplateVersionRetention.
func handleAdminListEmailTemplateVersions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
rows, err := dbSvc.emailTemplate.ListVersions(r.Context(), r.PathValue("key"), r.PathValue("lang"))
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// POST /api/admin/email-templates/{key}/{lang}/restore/{version_id} —
// copy a historical version back into the active row. Always appends a
// fresh version row that records the restore source.
func handleAdminRestoreEmailTemplateVersion(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
versionID, err := uuid.Parse(r.PathValue("version_id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid version id"})
return
}
ver, err := dbSvc.emailTemplate.RestoreVersion(r.Context(),
r.PathValue("key"), r.PathValue("lang"), versionID, uid)
if err != nil {
writeEmailTemplateError(w, err)
return
}
writeJSON(w, http.StatusOK, ver)
}
// handleAdminEmailTemplatesPage serves the SPA shell for the list page.
func handleAdminEmailTemplatesPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-email-templates.html")
}
// handleAdminEmailTemplatesEditPage serves the SPA shell for the editor.
// Same shell for every key — the client reads {key} from the URL and fetches
// the active row.
func handleAdminEmailTemplatesEditPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-email-templates-edit.html")
}
// writeEmailTemplateError maps EmailTemplateService sentinel errors to
// status codes the editor can react to. Anything else is 500.
func writeEmailTemplateError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrTemplateUnknownKey),
errors.Is(err, services.ErrTemplateVersionNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrTemplateUnknownLang):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrTemplateBodySyntax),
errors.Is(err, services.ErrTemplateSubjectSyntax),
errors.Is(err, services.ErrTemplateMissingContent),
errors.Is(err, services.ErrTemplateMissingBaseBlock):
writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrTemplateStoreUnavailable):
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}

View File

@@ -54,6 +54,7 @@ type Services struct {
Invite *services.InviteService
Agenda *services.AgendaService
Audit *services.AuditService
EmailTemplate *services.EmailTemplateService
}
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
@@ -80,6 +81,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
invite: svc.Invite,
agenda: svc.Agenda,
audit: svc.Audit,
emailTemplate: svc.EmailTemplate,
}
}
@@ -300,12 +302,22 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /admin", adminGate(users, gateOnboarded(handleAdminIndexPage)))
protected.HandleFunc("GET /admin/team", adminGate(users, gateOnboarded(handleAdminTeamPage)))
protected.HandleFunc("GET /admin/audit-log", adminGate(users, gateOnboarded(handleAdminAuditLogPage)))
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
protected.HandleFunc("PATCH /api/admin/users/{id}", adminGate(users, handleAdminUpdateUser))
protected.HandleFunc("DELETE /api/admin/users/{id}", adminGate(users, handleAdminDeleteUser))
protected.HandleFunc("GET /api/audit-log", adminGate(users, handleListAuditLog))
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))
protected.HandleFunc("PUT /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminSaveEmailTemplate))
protected.HandleFunc("POST /api/admin/email-templates/{key}/{lang}/preview", adminGate(users, handleAdminPreviewEmailTemplate))
protected.HandleFunc("POST /api/admin/email-templates/{key}/{lang}/reset", adminGate(users, handleAdminResetEmailTemplate))
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}/versions", adminGate(users, handleAdminListEmailTemplateVersions))
protected.HandleFunc("POST /api/admin/email-templates/{key}/{lang}/restore/{version_id}", adminGate(users, handleAdminRestoreEmailTemplateVersion))
}
// Catch-all 404 — runs for any authenticated path that no more-specific

View File

@@ -34,6 +34,7 @@ type dbServices struct {
invite *services.InviteService
agenda *services.AgendaService
audit *services.AuditService
emailTemplate *services.EmailTemplateService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,116 @@
// Sample data for the /admin/email-templates preview pane. Each preview
// request renders the proposed subject + body against this fixed payload
// so the admin sees the layout exactly as it will look in production for a
// representative case. Sample data is server-authoritative; the editor
// can't override it (out of scope for v1 — see design doc §9).
package services
// EmailTemplateSampleData returns a fresh sample payload for (key, lang).
// `slot` is honoured for deadline_digest only ("morning" / "evening"); other
// keys ignore it.
func EmailTemplateSampleData(key, lang, slot string) map[string]any {
switch key {
case EmailTemplateKeyInvitation:
return invitationSample(lang)
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestSample(lang, slot)
case EmailTemplateKeyBase:
return baseSample(lang)
default:
return map[string]any{}
}
}
func invitationSample(lang string) map[string]any {
if lang == "en" {
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "new.colleague@hlc.com",
"Message": "Hi — I think you'd find Paliad useful. Have a look.",
"RegisterURL": "https://paliad.de/login",
}
}
return map[string]any{
"InviterName": "Maria Schmidt",
"InviterEmail": "maria.schmidt@hlc.com",
"ToEmail": "neu.kollege@hlc.de",
"Message": "Hallo Kolleg:in — ich glaube Paliad wäre nützlich für dich. Schau es dir an.",
"RegisterURL": "https://paliad.de/login",
}
}
func deadlineDigestSample(lang, slot string) map[string]any {
isEvening := slot == "evening"
overdue := []map[string]any{
{
"DueDate": "2026-04-27",
"Title": ifLang(lang, "Beschwerde gegen EP-Anmeldung einreichen", "File appeal against EP application"),
"ProjectReference": "HL-2024-0083",
"ProjectTitle": "Acme vs Beta GmbH",
"OwnerName": "Maria Schmidt",
"IsOtherOwner": true,
"URL": "https://paliad.de/deadlines/sample-overdue-1",
},
}
dueToday := []map[string]any{
{
"DueDate": "2026-04-29",
"Title": ifLang(lang, "Klageerwiderung einreichen", "File reply to complaint"),
"ProjectReference": "HL-2025-0011",
"ProjectTitle": "Gamma AG vs Delta Inc",
"OwnerName": "Self",
"IsOtherOwner": false,
"URL": "https://paliad.de/deadlines/sample-due-today-1",
},
{
"DueDate": "2026-04-29",
"Title": ifLang(lang, "Vollmacht prüfen und gegenzeichnen", "Review and counter-sign power of attorney"),
"ProjectReference": "HL-2025-0014",
"ProjectTitle": "",
"OwnerName": "Self",
"IsOtherOwner": false,
"URL": "https://paliad.de/deadlines/sample-due-today-2",
},
}
dueWarning := []map[string]any{
{
"DueDate": "2026-05-06",
"Title": ifLang(lang, "Stellungnahme zur Erteilung vorbereiten", "Prepare response to grant"),
"ProjectReference": "HL-2025-0007",
"ProjectTitle": "Epsilon Ltd",
"OwnerName": "Self",
"IsOtherOwner": false,
"URL": "https://paliad.de/deadlines/sample-warning-1",
},
}
return map[string]any{
"Slot": slot,
"IsEvening": isEvening,
"Overdue": overdue,
"OverdueCount": len(overdue),
"DueToday": dueToday,
"DueTodayCount": len(dueToday),
"DueWarning": dueWarning,
"DueWarningCount": len(dueWarning),
"OpenTotal": len(dueToday) + len(dueWarning),
"DeadlinesURL": "https://paliad.de/deadlines",
}
}
func baseSample(lang string) map[string]any {
subj := "Beispielbetreff"
if lang == "en" {
subj = "Example subject"
}
return map[string]any{
"Subject": subj,
}
}
func ifLang(lang, de, en string) string {
if lang == "en" {
return en
}
return de
}

View File

@@ -0,0 +1,458 @@
// Package services — EmailTemplateService — manages the active and
// versioned email-template rows surfaced through /admin/email-templates.
//
// The service is intentionally tolerant of a nil DB: knowledge-platform-only
// deployments (no DATABASE_URL) still send invitations and reminders if
// SMTP is configured, falling back to the embedded per-language template
// files. Mutating methods (Save / Reset / RestoreVersion) require a DB and
// return ErrTemplateStoreUnavailable when called against a nil store.
//
// Active row precedence: a row in paliad.email_templates wins over the
// embedded default. Removing the row (Reset) restores the default. Saves
// also append to paliad.email_template_versions; the most recent
// EmailTemplateVersionRetention versions per (key, lang) are kept.
//
// See docs/design-email-templates-2026-04-29.md for the full design and
// the rationale for keeping subjects editable but seeded with explicit
// SLO-framing comments.
package services
import (
"context"
"database/sql"
"errors"
"fmt"
htmltemplate "html/template"
"slices"
"strings"
texttemplate "text/template"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/templates"
)
// Canonical template keys. The editor and seed loop iterate this list; new
// templates land by adding a key here and shipping a per-language body file
// under internal/templates/email/.
const (
EmailTemplateKeyInvitation = "invitation"
EmailTemplateKeyDeadlineDigest = "deadline_digest"
EmailTemplateKeyBase = "base"
)
// CanonicalEmailTemplateKeys is the closed set in canonical display order.
var CanonicalEmailTemplateKeys = []string{
EmailTemplateKeyInvitation,
EmailTemplateKeyDeadlineDigest,
EmailTemplateKeyBase,
}
// EmailTemplateVersionRetention caps the per-(key, lang) version history.
// Storage is negligible (3 keys × 2 langs × 20 = 120 rows steady-state) so
// 20 leaves enough headroom that the version a user wants to restore is
// almost always still around.
const EmailTemplateVersionRetention = 20
// EmailTemplateLanguages is the closed set of editor-supported languages.
var EmailTemplateLanguages = []string{"de", "en"}
// EmailTemplateRow is the active subject+body for one (key, lang). When the
// row came from the embedded fallback (no DB override) IsDefault is true and
// UpdatedAt / UpdatedBy are nil.
type EmailTemplateRow struct {
Key string `db:"key" json:"key"`
Lang string `db:"lang" json:"lang"`
Subject string `db:"subject" json:"subject"`
Body string `db:"body" json:"body"`
UpdatedAt *time.Time `db:"updated_at" json:"updated_at,omitempty"`
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
IsDefault bool `db:"-" json:"is_default"`
}
// EmailTemplateVersionRow is one entry in the per-(key, lang) save log.
type EmailTemplateVersionRow struct {
ID uuid.UUID `db:"id" json:"id"`
Key string `db:"key" json:"key"`
Lang string `db:"lang" json:"lang"`
Subject string `db:"subject" json:"subject"`
Body string `db:"body" json:"body"`
SavedAt time.Time `db:"saved_at" json:"saved_at"`
SavedBy *uuid.UUID `db:"saved_by" json:"saved_by,omitempty"`
Note string `db:"note" json:"note"`
}
// Sentinel errors so handlers can map cleanly to status codes.
var (
ErrTemplateUnknownKey = errors.New("unknown email template key")
ErrTemplateUnknownLang = errors.New("unknown email template language")
ErrTemplateBodySyntax = errors.New("template body has invalid syntax")
ErrTemplateSubjectSyntax = errors.New("template subject has invalid syntax")
ErrTemplateMissingContent = errors.New(`template body must contain {{define "content"}}{{end}}`)
ErrTemplateMissingBaseBlock = errors.New(`base template must keep {{block "content" .}}{{end}}`)
ErrTemplateStoreUnavailable = errors.New("email template store unavailable (DATABASE_URL not set)")
ErrTemplateVersionNotFound = errors.New("email template version not found")
)
// EmailTemplateService is the read/write authority for active + versioned
// rows. db may be nil — see package docs.
type EmailTemplateService struct {
db *sqlx.DB
}
// NewEmailTemplateService accepts a possibly-nil DB. Callers can hand it the
// shared sqlx.DB pool or pass nil for fallback-only mode.
func NewEmailTemplateService(db *sqlx.DB) *EmailTemplateService {
return &EmailTemplateService{db: db}
}
// HasStore reports whether mutating operations will succeed. False during
// knowledge-platform-only deployments.
func (s *EmailTemplateService) HasStore() bool { return s != nil && s.db != nil }
// IsCanonicalKey reports whether key is in the editor's closed set.
func IsCanonicalKey(key string) bool {
return slices.Contains(CanonicalEmailTemplateKeys, key)
}
func canonicaliseLang(lang string) (string, error) {
switch lang {
case "":
return "de", nil
case "de", "en":
return lang, nil
default:
return "", ErrTemplateUnknownLang
}
}
// GetActive returns the active subject+body for (key, lang). DB row wins
// when present; the embedded default applies otherwise. Errors only when
// the key/lang are unknown or the embedded default is missing.
func (s *EmailTemplateService) GetActive(ctx context.Context, key, lang string) (EmailTemplateRow, error) {
if !IsCanonicalKey(key) {
return EmailTemplateRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return EmailTemplateRow{}, err
}
if s.HasStore() {
var row EmailTemplateRow
err := s.db.GetContext(ctx, &row, `
SELECT key, lang, subject, body, updated_at, updated_by
FROM paliad.email_templates
WHERE key = $1 AND lang = $2`, key, lang)
if err == nil {
return row, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return EmailTemplateRow{}, fmt.Errorf("read email_templates: %w", err)
}
}
return embeddedDefault(key, lang)
}
// embeddedDefault returns the on-disk default for (key, lang). Subject
// defaults are Go consts (defaultSubjects) for two reasons: subjects are
// short, and the digest subject's conditional logic benefits from being
// colocated with the service that documents its intent.
func embeddedDefault(key, lang string) (EmailTemplateRow, error) {
body, err := readEmbeddedBody(key, lang)
if err != nil {
return EmailTemplateRow{}, err
}
return EmailTemplateRow{
Key: key,
Lang: lang,
Subject: defaultSubjects[key][lang],
Body: body,
IsDefault: true,
}, nil
}
func readEmbeddedBody(key, lang string) (string, error) {
path := fmt.Sprintf("email/%s.%s.html", key, lang)
data, err := templates.EmailFS.ReadFile(path)
if err != nil {
return "", fmt.Errorf("embedded body %s: %w", path, err)
}
return string(data), nil
}
// SaveInput is the payload for Save. Validation happens before any DB
// interaction so a typo is reported as 422 without a tx round-trip.
type SaveInput struct {
Key string
Lang string
Subject string
Body string
Note string // free-form admin annotation, optional
SavedBy uuid.UUID // uuid.Nil when actor is unknown — column is nullable
}
// Save validates and upserts the active row, appends a version, and GCs the
// version log to EmailTemplateVersionRetention. All in one transaction.
func (s *EmailTemplateService) Save(ctx context.Context, in SaveInput) (EmailTemplateVersionRow, error) {
if !s.HasStore() {
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(in.Key) {
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(in.Lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
in.Lang = lang
if err := ValidateTemplate(in.Key, in.Subject, in.Body); err != nil {
return EmailTemplateVersionRow{}, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `
INSERT INTO paliad.email_templates (key, lang, subject, body, updated_at, updated_by)
VALUES ($1, $2, $3, $4, now(), $5)
ON CONFLICT (key, lang) DO UPDATE
SET subject = EXCLUDED.subject,
body = EXCLUDED.body,
updated_at = now(),
updated_by = EXCLUDED.updated_by`,
in.Key, in.Lang, in.Subject, in.Body, nullableUUID(in.SavedBy)); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("upsert active: %w", err)
}
ver, err := insertVersion(ctx, tx, in.Key, in.Lang, in.Subject, in.Body, in.SavedBy, in.Note)
if err != nil {
return EmailTemplateVersionRow{}, err
}
if err := gcVersions(ctx, tx, in.Key, in.Lang); err != nil {
return EmailTemplateVersionRow{}, err
}
if err := tx.Commit(); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
}
return ver, nil
}
// Reset deletes the active row and appends a 'reset' version capturing the
// embedded default. Subsequent renders fall back to the embedded body.
func (s *EmailTemplateService) Reset(ctx context.Context, key, lang string, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
if !s.HasStore() {
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(key) {
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
def, err := embeddedDefault(key, lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.email_templates WHERE key = $1 AND lang = $2`,
key, lang); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("delete active: %w", err)
}
ver, err := insertVersion(ctx, tx, key, lang, def.Subject, def.Body, savedBy, "reset")
if err != nil {
return EmailTemplateVersionRow{}, err
}
if err := gcVersions(ctx, tx, key, lang); err != nil {
return EmailTemplateVersionRow{}, err
}
if err := tx.Commit(); err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("commit: %w", err)
}
return ver, nil
}
// ListVersions returns the most recent EmailTemplateVersionRetention rows.
func (s *EmailTemplateService) ListVersions(ctx context.Context, key, lang string) ([]EmailTemplateVersionRow, error) {
if !s.HasStore() {
return nil, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(key) {
return nil, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return nil, err
}
rows := []EmailTemplateVersionRow{}
if err := s.db.SelectContext(ctx, &rows, `
SELECT id, key, lang, subject, body, saved_at, saved_by, note
FROM paliad.email_template_versions
WHERE key = $1 AND lang = $2
ORDER BY saved_at DESC
LIMIT $3`, key, lang, EmailTemplateVersionRetention); err != nil {
return nil, fmt.Errorf("list versions: %w", err)
}
return rows, nil
}
// RestoreVersion copies a historical version back into the active row,
// appending a fresh version that records the restore source.
func (s *EmailTemplateService) RestoreVersion(ctx context.Context, key, lang string, versionID, savedBy uuid.UUID) (EmailTemplateVersionRow, error) {
if !s.HasStore() {
return EmailTemplateVersionRow{}, ErrTemplateStoreUnavailable
}
if !IsCanonicalKey(key) {
return EmailTemplateVersionRow{}, ErrTemplateUnknownKey
}
lang, err := canonicaliseLang(lang)
if err != nil {
return EmailTemplateVersionRow{}, err
}
var src EmailTemplateVersionRow
err = s.db.GetContext(ctx, &src, `
SELECT id, key, lang, subject, body, saved_at, saved_by, note
FROM paliad.email_template_versions
WHERE id = $1 AND key = $2 AND lang = $3`, versionID, key, lang)
if errors.Is(err, sql.ErrNoRows) {
return EmailTemplateVersionRow{}, ErrTemplateVersionNotFound
}
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("fetch version: %w", err)
}
return s.Save(ctx, SaveInput{
Key: key,
Lang: lang,
Subject: src.Subject,
Body: src.Body,
Note: fmt.Sprintf("restore from %s", versionID),
SavedBy: savedBy,
})
}
// ValidateTemplate checks subject + body against the templating engines.
// The structural checks ensure content templates re-define {{block "content"}}
// (otherwise the body silently vanishes inside the base wrapper) and that
// the base body still contains the {{block "content" .}} call (otherwise
// every email loses its body). Returns nil iff both are syntactically and
// structurally valid.
func ValidateTemplate(key, subject, body string) error {
if subject != "" {
if _, err := texttemplate.New("subject").Parse(subject); err != nil {
return fmt.Errorf("%w: %v", ErrTemplateSubjectSyntax, err)
}
}
if _, err := htmltemplate.New("body").Parse(body); err != nil {
return fmt.Errorf("%w: %v", ErrTemplateBodySyntax, err)
}
if key == EmailTemplateKeyBase {
if !strings.Contains(body, `block "content"`) {
return ErrTemplateMissingBaseBlock
}
} else {
if !strings.Contains(body, `define "content"`) {
return ErrTemplateMissingContent
}
}
return nil
}
// --- internal helpers -------------------------------------------------------
func insertVersion(ctx context.Context, tx *sqlx.Tx, key, lang, subject, body string, savedBy uuid.UUID, note string) (EmailTemplateVersionRow, error) {
var ver EmailTemplateVersionRow
err := tx.GetContext(ctx, &ver, `
INSERT INTO paliad.email_template_versions (key, lang, subject, body, saved_by, note)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, key, lang, subject, body, saved_at, saved_by, note`,
key, lang, subject, body, nullableUUID(savedBy), note)
if err != nil {
return EmailTemplateVersionRow{}, fmt.Errorf("insert version: %w", err)
}
return ver, nil
}
func gcVersions(ctx context.Context, tx *sqlx.Tx, key, lang string) error {
if _, err := tx.ExecContext(ctx, `
DELETE FROM paliad.email_template_versions
WHERE key = $1 AND lang = $2
AND id NOT IN (
SELECT id FROM paliad.email_template_versions
WHERE key = $1 AND lang = $2
ORDER BY saved_at DESC
LIMIT $3
)`, key, lang, EmailTemplateVersionRetention); err != nil {
return fmt.Errorf("gc versions: %w", err)
}
return nil
}
func nullableUUID(u uuid.UUID) any {
if u == uuid.Nil {
return nil
}
return u
}
// defaultSubjects is the embedded-fallback subject template per (key, lang).
// They're text/template (not html/template); subjects are plain strings,
// no HTML escaping required.
//
// Keep the SYSTEMAUSFALL / SYSTEM FAILURE phrasing — see
// docs/design-reminder-redesign-2026-04-28.md. Softening it weakens the
// zero-overdue SLO and the comment in the seed exists so the next admin
// who edits sees the rationale instead of pattern-matching the framing as
// noise.
var defaultSubjects = map[string]map[string]string{
EmailTemplateKeyInvitation: {
"de": `[Paliad] {{.InviterName}} lädt Sie zu Paliad ein`,
"en": `[Paliad] {{.InviterName}} invites you to Paliad`,
},
EmailTemplateKeyDeadlineDigest: {
"de": digestSubjectDE,
"en": digestSubjectEN,
},
EmailTemplateKeyBase: {"de": "", "en": ""},
}
const digestSubjectDE = `{{- /* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
{{- if and .IsEvening (gt .OverdueCount 0) -}}
[Paliad] SYSTEMAUSFALL: {{.OverdueCount}} überfällig — plus {{.DueTodayCount}} heute offen
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
[Paliad] DRINGEND — {{.DueTodayCount}} heute noch offen
{{- else if .IsEvening -}}
[Paliad] {{.OverdueCount}} überfällig
{{- else if gt .OverdueCount 0 -}}
[Paliad] ÜBERFÄLLIG: {{.OverdueCount}} — plus {{.OpenTotal}} weitere
{{- else if eq .OpenTotal 1 -}}
[Paliad] Frist-Erinnerung: 1 offen
{{- else -}}
[Paliad] Frist-Erinnerung: {{.OpenTotal}} offen
{{- end -}}`
const digestSubjectEN = `{{- /* keep the SYSTEM FAILURE phrasing — see docs/design-reminder-redesign-2026-04-28.md */ -}}
{{- if and .IsEvening (gt .OverdueCount 0) -}}
[Paliad] SYSTEM FAILURE: {{.OverdueCount}} overdue — plus {{.DueTodayCount}} still open today
{{- else if and .IsEvening (gt .DueTodayCount 0) -}}
[Paliad] URGENT — {{.DueTodayCount}} still open today
{{- else if .IsEvening -}}
[Paliad] {{.OverdueCount}} overdue
{{- else if gt .OverdueCount 0 -}}
[Paliad] OVERDUE: {{.OverdueCount}} — plus {{.OpenTotal}} more
{{- else if eq .OpenTotal 1 -}}
[Paliad] Deadline reminder: 1 open
{{- else -}}
[Paliad] Deadline reminder: {{.OpenTotal}} open
{{- end -}}`

View File

@@ -0,0 +1,157 @@
package services
import (
"context"
"strings"
"testing"
)
// TestGetActiveEmbeddedFallback covers the no-DB path. Without a sqlx.DB,
// every key/lang pair returns the embedded default with IsDefault=true.
func TestGetActiveEmbeddedFallback(t *testing.T) {
svc := NewEmailTemplateService(nil)
for _, key := range CanonicalEmailTemplateKeys {
for _, lang := range EmailTemplateLanguages {
row, err := svc.GetActive(context.Background(), key, lang)
if err != nil {
t.Errorf("GetActive(%s, %s): %v", key, lang, err)
continue
}
if !row.IsDefault {
t.Errorf("GetActive(%s, %s): IsDefault=false on no-DB service", key, lang)
}
if row.Body == "" {
t.Errorf("GetActive(%s, %s): empty body", key, lang)
}
if key == EmailTemplateKeyBase {
if row.Subject != "" {
t.Errorf("base subject expected empty, got %q", row.Subject)
}
} else if row.Subject == "" {
t.Errorf("GetActive(%s, %s): empty subject for non-base key", key, lang)
}
}
}
}
// TestGetActiveUnknownKey ensures unknown keys are 404-shaped.
func TestGetActiveUnknownKey(t *testing.T) {
svc := NewEmailTemplateService(nil)
if _, err := svc.GetActive(context.Background(), "nope", "de"); err != ErrTemplateUnknownKey {
t.Errorf("expected ErrTemplateUnknownKey, got %v", err)
}
}
// TestGetActiveUnknownLang ensures unknown languages are 400-shaped.
func TestGetActiveUnknownLang(t *testing.T) {
svc := NewEmailTemplateService(nil)
if _, err := svc.GetActive(context.Background(), EmailTemplateKeyInvitation, "fr"); err != ErrTemplateUnknownLang {
t.Errorf("expected ErrTemplateUnknownLang, got %v", err)
}
}
// TestSaveRequiresStore checks that mutations against a no-DB service fail
// closed — no silent acceptance, no in-memory drift.
func TestSaveRequiresStore(t *testing.T) {
svc := NewEmailTemplateService(nil)
_, err := svc.Save(context.Background(), SaveInput{
Key: EmailTemplateKeyInvitation,
Lang: "de",
Subject: "x",
Body: `{{define "content"}}<p>x</p>{{end}}`,
})
if err != ErrTemplateStoreUnavailable {
t.Errorf("expected ErrTemplateStoreUnavailable, got %v", err)
}
}
// TestValidateTemplate checks every code path of the validation function.
// Save and the preview endpoint both lean on this — a drift here breaks both.
func TestValidateTemplate(t *testing.T) {
cases := []struct {
name string
key, subj string
body string
wantErr error
wantSubsErr string // substring required in the wrapped error
}{
{
"invitation valid",
EmailTemplateKeyInvitation,
"[Paliad] {{.InviterName}} invites you",
`{{define "content"}}<h1>{{.InviterName}}</h1>{{end}}`,
nil, "",
},
{
"invitation missing content block",
EmailTemplateKeyInvitation,
"x",
`<p>oops, no define</p>`,
ErrTemplateMissingContent, "",
},
{
"invitation bad body syntax",
EmailTemplateKeyInvitation,
"x",
`{{define "content"}}<p>{{.InviterName}{{end}}`,
nil, "syntax",
},
{
"invitation bad subject syntax",
EmailTemplateKeyInvitation,
`[Paliad] {{.InviterName`,
`{{define "content"}}<p>x</p>{{end}}`,
nil, "syntax",
},
{
"base valid",
EmailTemplateKeyBase,
"",
`<html><body>{{block "content" .}}{{end}}</body></html>`,
nil, "",
},
{
"base missing block call",
EmailTemplateKeyBase,
"",
`<html><body>no inner</body></html>`,
ErrTemplateMissingBaseBlock, "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateTemplate(tc.key, tc.subj, tc.body)
if tc.wantErr != nil {
if err != tc.wantErr {
t.Errorf("got %v, want %v", err, tc.wantErr)
}
return
}
if tc.wantSubsErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantSubsErr) {
t.Errorf("got %v, want substring %q", err, tc.wantSubsErr)
}
return
}
if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
// TestEmailTemplateVariablesShape ensures every canonical key has a non-empty
// variable contract — the editor sidebar would render an empty box otherwise.
func TestEmailTemplateVariablesShape(t *testing.T) {
for _, key := range CanonicalEmailTemplateKeys {
vars := EmailTemplateVariables(key)
if len(vars) == 0 {
t.Errorf("key %s: no variables registered", key)
}
for _, v := range vars {
if v.Name == "" || v.Type == "" || v.Description == "" {
t.Errorf("key %s: variable %+v has empty field", key, v)
}
}
}
}

View File

@@ -0,0 +1,121 @@
// Variable contracts for the /admin/email-templates editor. Surfaces in the
// editor sidebar so an admin sees what `{{.Foo}}` placeholders are
// available and what each renders to with sample data. Single source of
// truth for both the docs and the sample data.
package services
// EmailTemplateVariable describes one placeholder a template may reference.
// Type is informational ("string", "[]Row", "bool", etc.); the editor uses
// it for rendering, not for validation.
type EmailTemplateVariable struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
SampleDE string `json:"sample_de"`
SampleEN string `json:"sample_en"`
}
// EmailTemplateVariables returns the variable contract for (key). The list
// is identical across languages; the SampleDE/SampleEN fields differ.
func EmailTemplateVariables(key string) []EmailTemplateVariable {
switch key {
case EmailTemplateKeyInvitation:
return invitationVariables
case EmailTemplateKeyDeadlineDigest:
return deadlineDigestVariables
case EmailTemplateKeyBase:
return baseVariables
default:
return nil
}
}
var invitationVariables = []EmailTemplateVariable{
{Name: ".InviterName", Type: "string",
Description: "Anzeigename der einladenden Person.",
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
{Name: ".InviterEmail", Type: "string",
Description: "E-Mail-Adresse der einladenden Person.",
SampleDE: "maria.schmidt@hlc.com", SampleEN: "maria.schmidt@hlc.com"},
{Name: ".ToEmail", Type: "string",
Description: "Empfänger:in der Einladung.",
SampleDE: "neu.kollege@hlc.de", SampleEN: "new.colleague@hlc.com"},
{Name: ".Message", Type: "string (optional)",
Description: "Persönliche Nachricht der einladenden Person. Der Body sollte den Block mit {{if .Message}}…{{end}} umschliessen.",
SampleDE: "Hallo Kolleg:in — schau es dir an.", SampleEN: "Hi — have a look."},
{Name: ".RegisterURL", Type: "string",
Description: "Link zum Login/Registrierungs-Endpunkt.",
SampleDE: "https://paliad.de/login", SampleEN: "https://paliad.de/login"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME). Wird im Body und Footer verwendet.",
SampleDE: "HLC", SampleEN: "HLC"},
}
var deadlineDigestVariables = []EmailTemplateVariable{
{Name: ".Slot", Type: "string",
Description: "Trigger-Slot: \"morning\" oder \"evening\". Body verwendet typischerweise .IsEvening.",
SampleDE: "morning", SampleEN: "morning"},
{Name: ".IsEvening", Type: "bool",
Description: "True im Abend-Slot — steuert die DRINGEND/URGENT-Headline.",
SampleDE: "false", SampleEN: "false"},
{Name: ".Overdue", Type: "[]Row",
Description: "Überfällige Fristen. Iteriere mit {{range .Overdue}}…{{end}}.",
SampleDE: "1 Eintrag", SampleEN: "1 entry"},
{Name: ".OverdueCount", Type: "int",
Description: "Länge von .Overdue, vorgerechnet für Überschriften.",
SampleDE: "1", SampleEN: "1"},
{Name: ".DueToday", Type: "[]Row",
Description: "Heute fällige Fristen.",
SampleDE: "2 Einträge", SampleEN: "2 entries"},
{Name: ".DueTodayCount", Type: "int",
Description: "Länge von .DueToday.",
SampleDE: "2", SampleEN: "2"},
{Name: ".DueWarning", Type: "[]Row",
Description: "Innerhalb der Vorwarnung fällige Fristen (typisch: ≤ 7 Tage).",
SampleDE: "1 Eintrag", SampleEN: "1 entry"},
{Name: ".DueWarningCount", Type: "int",
Description: "Länge von .DueWarning.",
SampleDE: "1", SampleEN: "1"},
{Name: ".OpenTotal", Type: "int",
Description: "Summe von .DueTodayCount + .DueWarningCount, vorgerechnet für die Betreffzeile.",
SampleDE: "3", SampleEN: "3"},
{Name: ".DeadlinesURL", Type: "string",
Description: "Ziel des „Alle Fristen / All deadlines\"-Buttons.",
SampleDE: "https://paliad.de/deadlines", SampleEN: "https://paliad.de/deadlines"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME).",
SampleDE: "HLC", SampleEN: "HLC"},
{Name: "Row.DueDate", Type: "string (ISO)",
Description: "Fälligkeitsdatum, ISO YYYY-MM-DD.",
SampleDE: "2026-04-29", SampleEN: "2026-04-29"},
{Name: "Row.Title", Type: "string",
Description: "Frist-Titel.",
SampleDE: "Klageerwiderung einreichen", SampleEN: "File reply to complaint"},
{Name: "Row.ProjectReference", Type: "string",
Description: "Akten-/Projekt-Aktenzeichen.",
SampleDE: "HL-2025-0011", SampleEN: "HL-2025-0011"},
{Name: "Row.ProjectTitle", Type: "string (optional)",
Description: "Projekt-Titel; kann leer sein.",
SampleDE: "Gamma AG vs Delta Inc", SampleEN: "Gamma AG vs Delta Inc"},
{Name: "Row.OwnerName", Type: "string",
Description: "Eigentümer:in der Frist.",
SampleDE: "Maria Schmidt", SampleEN: "Maria Schmidt"},
{Name: "Row.IsOtherOwner", Type: "bool",
Description: "True wenn die Frist nicht der/dem Empfänger:in gehört (zeigt Eigentümer-Hinweis).",
SampleDE: "true", SampleEN: "true"},
{Name: "Row.URL", Type: "string",
Description: "Direktlink zur Frist-Detailseite.",
SampleDE: "https://paliad.de/deadlines/<uuid>", SampleEN: "https://paliad.de/deadlines/<uuid>"},
}
var baseVariables = []EmailTemplateVariable{
{Name: ".Lang", Type: "string",
Description: "Sprache der Mail. Wird in <html lang=\"…\"> eingesetzt.",
SampleDE: "de", SampleEN: "en"},
{Name: ".Subject", Type: "string",
Description: "Vom Inhalts-Template übergebene Betreffzeile. Erscheint im <title>-Tag.",
SampleDE: "Beispielbetreff", SampleEN: "Example subject"},
{Name: ".Firm", Type: "string",
Description: "Firmenname (FIRM_NAME).",
SampleDE: "HLC", SampleEN: "HLC"},
}

View File

@@ -100,13 +100,10 @@ func (s *InviteService) Send(ctx context.Context, fromUserID uuid.UUID, inviter
lang = "en"
}
subject := inviteSubject(lang, inviter.DisplayName)
if err := s.mail.SendTemplate(TemplateData{
To: to,
Subject: subject,
Lang: lang,
Name: "invitation",
To: to,
Lang: lang,
Name: "invitation",
Data: map[string]any{
"InviterName": inviter.DisplayName,
"InviterEmail": inviter.Email,
@@ -215,9 +212,3 @@ func (s *InviteService) insertRow(ctx context.Context, fromUserID uuid.UUID, toE
return id, err
}
func inviteSubject(lang, inviterName string) string {
if lang == "en" {
return fmt.Sprintf("[Paliad] %s invites you to Paliad", inviterName)
}
return fmt.Sprintf("[Paliad] %s lädt Sie zu Paliad ein", inviterName)
}

View File

@@ -1,13 +1,15 @@
// Package services — MailService — SMTP delivery for transactional email.
//
// Handles three kinds of messages: deadline reminders (reminder_service.go),
// colleague invitations (handlers/invite.go), and any other one-off email the
// app needs to send. All outgoing mail goes through Send / SendTemplate so
// branding stays consistent.
// Handles two flows today: deadline reminders (reminder_service.go) and
// colleague invitations (handlers/invite.go). Body and subject for both come
// from EmailTemplateService — a DB-backed override falls through to the
// embedded per-language file when no override exists. See
// docs/design-email-templates-2026-04-29.md for the override semantics.
//
// Config is read from env vars at startup; when any required var is unset the
// service logs a warning and becomes a silent no-op. This lets the server run
// locally without SMTP credentials — no crashes, no surprise 500s.
// Config is read from env vars at startup; when any required var is unset
// the service logs a warning and becomes a silent no-op. This lets the
// server run locally without SMTP credentials — no crashes, no surprise
// 500s.
//
// Port 465 uses implicit TLS (tls.Dial from the start), not STARTTLS. The
// Hostinger submission endpoint only accepts implicit TLS on that port.
@@ -15,10 +17,11 @@ package services
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"html/template"
htmltemplate "html/template"
"log/slog"
"maps"
"mime"
@@ -27,10 +30,10 @@ import (
"os"
"regexp"
"strings"
texttemplate "text/template"
"time"
"mgit.msbls.de/m/patholo/internal/branding"
"mgit.msbls.de/m/patholo/internal/templates"
)
// MailConfig holds resolved SMTP settings. Built once at startup.
@@ -47,16 +50,20 @@ type MailConfig struct {
// MailService sends branded HTML+text email over SMTP. Safe to use
// concurrently. When the service is disabled (missing env vars), every Send*
// call logs and returns nil so callers can treat it as fire-and-forget.
//
// Templates are looked up via EmailTemplateService at render time, so an
// admin save propagates without a process restart. The service is created
// without one and gets it via SetTemplateService — wiring order in main.go
// (mail before DB pool) makes constructor injection awkward.
type MailService struct {
cfg MailConfig
enabled bool
templates *template.Template
cfg MailConfig
enabled bool
templateSvc *EmailTemplateService
}
// NewMailService reads SMTP_* from the environment. Returns a non-nil service
// either way; callers check Enabled() if they care whether mail actually went
// out. Parsing the embedded template set is fatal — a template error would
// silently break every email, which is worse than failing at boot.
// NewMailService reads SMTP_* from the environment. Returns a non-nil
// service either way; callers check Enabled() if they care whether mail
// actually went out. Template lookup is bound later via SetTemplateService.
func NewMailService() (*MailService, error) {
cfg := MailConfig{
Host: strings.TrimSpace(os.Getenv("SMTP_HOST")),
@@ -89,16 +96,23 @@ func NewMailService() (*MailService, error) {
slog.Info("mail: SMTP configured", "host", cfg.Host, "port", cfg.Port, "from", cfg.From)
}
// Parse only base.html here. Each content template redefines
// {{define "content"}}; parsing them all at once would silently let the
// last one win. SendTemplate parses the chosen content file onto a clone
// of the base so every call gets the right override.
tpls, err := template.ParseFS(templates.EmailFS, "email/base.html")
if err != nil {
return nil, fmt.Errorf("parse email base template: %w", err)
}
// Default to a fallback-only EmailTemplateService (nil DB). Tests that
// don't wire a DB still get rendering against the embedded files.
return &MailService{
cfg: cfg,
enabled: enabled,
templateSvc: NewEmailTemplateService(nil),
}, nil
}
return &MailService{cfg: cfg, enabled: enabled, templates: tpls}, nil
// SetTemplateService swaps the in-use EmailTemplateService. main.go calls
// this once after the DB pool is up so DB-backed overrides start applying.
// Safe to call before any Send/Render — there's no concurrent access yet.
func (s *MailService) SetTemplateService(t *EmailTemplateService) {
if t == nil {
t = NewEmailTemplateService(nil)
}
s.templateSvc = t
}
// Enabled reports whether SMTP is configured. Handlers can surface a clearer
@@ -131,28 +145,20 @@ func (s *MailService) Send(to, subject, htmlBody, textBody string) error {
}
// TemplateData is the shape passed to SendTemplate. Lang defaults to "de"
// when empty. Subject is set by the caller (it can use Data in its own
// formatting before calling Send). To is the recipient; Data holds template
// fields. Name is the template's {{define "content"}} name — i.e.
// "deadline_reminder", "deadline_weekly", or "invitation".
// when empty. Subject is no longer set by the caller — it's looked up and
// rendered from the (key, lang) row.
type TemplateData struct {
To string
Subject string
Lang string
Name string
Data map[string]any
To string
Lang string
Name string
Data map[string]any
}
// SendTemplate renders the named content template inside the shared base
// layout and sends both HTML and a plain-text fallback. The fallback comes
// from tag-stripping the rendered HTML; for richer text output we can add
// a parallel .txt template later without changing callers.
//
// Rendering runs even when Enabled() is false, so template errors (typos,
// missing fields) surface in development and in tests that don't set
// SMTP_*. Actual network I/O is skipped in that case.
// SendTemplate renders subject + body via EmailTemplateService and sends.
// Render errors surface even when SMTP is disabled — that catches typos and
// missing fields in dev/test where SMTP isn't configured.
func (s *MailService) SendTemplate(in TemplateData) error {
html, err := s.RenderTemplate(in)
subject, html, err := s.RenderTemplate(in)
if err != nil {
return err
}
@@ -160,45 +166,189 @@ func (s *MailService) SendTemplate(in TemplateData) error {
slog.Debug("mail: SendTemplate skipped (disabled)", "to", in.To, "template", in.Name)
return nil
}
return s.Send(in.To, in.Subject, html, htmlToText(html))
return s.Send(in.To, subject, html, htmlToText(html))
}
// RenderTemplate produces the final HTML body without touching the network.
// Exposed for tests and for any future flow that wants to preview the
// rendered email (e.g. an admin tool).
func (s *MailService) RenderTemplate(in TemplateData) (string, error) {
// RenderTemplate produces the rendered subject and HTML body. Falls back to
// the embedded default if a DB row is malformed at parse time — admins can
// never wedge the send path with a bad save (per design-email-templates §3).
// Exposed for tests and the admin preview endpoint.
func (s *MailService) RenderTemplate(in TemplateData) (subject string, html string, err error) {
lang := in.Lang
if lang == "" {
lang = "de"
}
// We need to bind the right {{define "content"}} — each of our templates
// redefines it. Clone the base template and parse the chosen file so only
// one definition is active for this render.
tpl, err := s.templates.Clone()
if err != nil {
return "", fmt.Errorf("clone templates: %w", err)
}
contentFile := in.Name + ".html"
_, err = tpl.ParseFS(templates.EmailFS, "email/"+contentFile)
if err != nil {
return "", fmt.Errorf("parse template %s: %w", contentFile, err)
}
ctx := context.Background()
// Firm is injected from branding.Name so every email template can render
// the current firm name via {{.Firm}} without each caller threading it in.
// Caller-provided Data still wins (in.Data is copied last) — useful in
// tests that want to assert a specific firm string.
// Build payload once — both subject and body use the same data.
payload := map[string]any{
"Lang": lang,
"Subject": in.Subject,
"Firm": branding.Name,
}
maps.Copy(payload, in.Data)
// Body comes from (key, lang); on parse error fall back to the embedded
// default so a corrupt DB row never breaks delivery.
body, _, err := s.lookupBody(ctx, in.Name, lang)
if err != nil {
return "", "", fmt.Errorf("lookup body %s: %w", in.Name, err)
}
// Base wrapper: same lookup, key='base'.
baseBody, _, err := s.lookupBody(ctx, EmailTemplateKeyBase, lang)
if err != nil {
return "", "", fmt.Errorf("lookup base: %w", err)
}
// Compose: parse base, then layer the content body on top. If either
// parse fails on the active row, retry with the embedded default.
html, err = renderBaseAndContent(baseBody, body, payload)
if err != nil {
// Active row was bad. Pull the embedded fallback for both and retry —
// log loudly so the admin can fix it, but keep email working.
slog.Error("mail: active template parse failed, falling back to embedded default",
"key", in.Name, "lang", lang, "err", err)
fbContent, fbErr := readEmbeddedBody(in.Name, lang)
if fbErr != nil {
return "", "", fmt.Errorf("fallback body %s: %w", in.Name, fbErr)
}
fbBase, fbErr := readEmbeddedBody(EmailTemplateKeyBase, lang)
if fbErr != nil {
return "", "", fmt.Errorf("fallback base: %w", fbErr)
}
html, err = renderBaseAndContent(fbBase, fbContent, payload)
if err != nil {
return "", "", fmt.Errorf("render embedded fallback %s: %w", in.Name, err)
}
}
// Subject: same fallback discipline. Empty subject template (key='base'
// case, when SendTemplate is somehow asked for the wrapper directly) is
// allowed and returns "".
subjectTpl, _, err := s.lookupSubject(ctx, in.Name, lang)
if err != nil {
return "", "", fmt.Errorf("lookup subject %s: %w", in.Name, err)
}
subject, err = renderSubject(subjectTpl, payload)
if err != nil {
slog.Error("mail: active subject parse failed, falling back to embedded default",
"key", in.Name, "lang", lang, "err", err)
fbSubj := defaultSubjects[in.Name][lang]
fbSubject, fbErr := renderSubject(fbSubj, payload)
if fbErr != nil {
return "", "", fmt.Errorf("render embedded fallback subject %s: %w", in.Name, fbErr)
}
subject = fbSubject
}
return subject, html, nil
}
// lookupBody returns the body string + IsDefault marker. When the template
// service has no DB or the row is missing, the embedded body wins.
func (s *MailService) lookupBody(ctx context.Context, key, lang string) (string, bool, error) {
row, err := s.templateSvc.GetActive(ctx, key, lang)
if err != nil {
return "", false, err
}
return row.Body, row.IsDefault, nil
}
// lookupSubject mirrors lookupBody for subjects.
func (s *MailService) lookupSubject(ctx context.Context, key, lang string) (string, bool, error) {
row, err := s.templateSvc.GetActive(ctx, key, lang)
if err != nil {
return "", false, err
}
return row.Subject, row.IsDefault, nil
}
// RenderPreview renders user-supplied subject+body against the active base
// wrapper (or embedded fallback) for (lang). Used by the admin preview
// endpoint — never persists. Sample data is supplied by the caller and is
// merged on top of {Lang, Firm} so previews see the same baseline payload
// the production render would.
//
// For key=='base' the user-supplied body IS the wrapper; we layer a small
// built-in content sample on top so the preview shows what an inner email
// looks like inside the proposed wrapper.
func (s *MailService) RenderPreview(key, lang, subjectSrc, bodySrc string, data map[string]any) (string, string, error) {
if lang == "" {
lang = "de"
}
payload := map[string]any{
"Lang": lang,
"Firm": branding.Name,
}
maps.Copy(payload, data)
var (
baseBody, contentBody string
err error
)
if key == EmailTemplateKeyBase {
baseBody = bodySrc
contentBody = previewBaseInnerContent(lang)
} else {
baseBody, _, err = s.lookupBody(context.Background(), EmailTemplateKeyBase, lang)
if err != nil {
return "", "", fmt.Errorf("lookup base: %w", err)
}
contentBody = bodySrc
}
html, err := renderBaseAndContent(baseBody, contentBody, payload)
if err != nil {
return "", "", err
}
subject, err := renderSubject(subjectSrc, payload)
if err != nil {
return "", "", err
}
return subject, html, nil
}
// previewBaseInnerContent is the placeholder body wrapped during a base
// preview. Kept tiny so the preview pane shows the wrapper, not a fake email.
func previewBaseInnerContent(lang string) string {
if lang == "en" {
return `{{define "content"}}
<p>Inner content of the specific email is rendered here.</p>
<p>Example link: <a href="https://paliad.de">paliad.de</a>.</p>
{{end}}`
}
return `{{define "content"}}
<p>Inhalt der spezifischen Mail wird hier gerendert.</p>
<p>Beispiel-Link: <a href="https://paliad.de">paliad.de</a>.</p>
{{end}}`
}
func renderBaseAndContent(baseBody, contentBody string, payload map[string]any) (string, error) {
tpl, err := htmltemplate.New("base.html").Parse(baseBody)
if err != nil {
return "", fmt.Errorf("parse base: %w", err)
}
if _, err := tpl.Parse(contentBody); err != nil {
return "", fmt.Errorf("parse content: %w", err)
}
var out bytes.Buffer
if err := tpl.ExecuteTemplate(&out, "base.html", payload); err != nil {
return "", fmt.Errorf("render template %s: %w", in.Name, err)
return "", fmt.Errorf("execute: %w", err)
}
return out.String(), nil
}
func renderSubject(src string, payload map[string]any) (string, error) {
if strings.TrimSpace(src) == "" {
return "", nil
}
tpl, err := texttemplate.New("subject").Parse(src)
if err != nil {
return "", fmt.Errorf("parse subject: %w", err)
}
var out bytes.Buffer
if err := tpl.Execute(&out, payload); err != nil {
return "", fmt.Errorf("execute subject: %w", err)
}
return out.String(), nil
}
@@ -268,8 +418,8 @@ func hostnameForHelo() string {
// --- MIME construction ------------------------------------------------------
// buildMIME assembles a multipart/alternative message with a fixed boundary.
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters (umlauts)
// render correctly in every client.
// Subjects are encoded as UTF-8 per RFC 2047 so non-ASCII characters
// (umlauts) render correctly in every client.
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
boundary := "paliad-mixed-" + randBoundary()
fromHeader := from
@@ -302,8 +452,8 @@ func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
return b.Bytes()
}
// randBoundary produces a short unique boundary marker. Crypto-strength isn't
// required — we only need to avoid collision with the body content.
// randBoundary produces a short unique boundary marker. Crypto-strength
// isn't required — we only need to avoid collision with the body content.
func randBoundary() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}

View File

@@ -29,36 +29,29 @@ func TestHTMLToText(t *testing.T) {
}
// TestRenderTemplateDeadlineDigest verifies the bundled-digest template
// (t-paliad-064) renders all three category sections, applies the
// DRINGEND wording on the evening slot, and folds in IsOtherOwner labels
// when a row's owner isn't the recipient. A typo in deadline_digest.html
// would fail here before any SMTP I/O.
// renders all three category sections, applies the DRINGEND wording on the
// evening slot, and folds in IsOtherOwner labels when a row's owner isn't
// the recipient. A typo in deadline_digest.de.html would fail here before
// any SMTP I/O.
//
// Also asserts that the rendered subject picks up the evening DRINGEND
// framing — the SLO-critical phrasing must survive the template-render
// path, not just the body.
func TestRenderTemplateDeadlineDigest(t *testing.T) {
svc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
html, err := svc.RenderTemplate(TemplateData{
Subject: "[Paliad] DRINGEND — 1 heute noch offen",
Lang: "de",
Name: "deadline_digest",
subject, html, err := svc.RenderTemplate(TemplateData{
Lang: "de",
Name: "deadline_digest",
Data: map[string]any{
"Slot": "evening",
"IsEvening": true,
"OverdueCount": 1,
"OverdueCount": 0,
"DueTodayCount": 1,
"DueWarningCount": 0,
"Overdue": []map[string]any{
{
"DueDate": "2026-04-27",
"Title": "Schon überfällig",
"ProjectReference": "2026/0001",
"ProjectTitle": "Acme v Widget",
"OwnerName": "Anna Schmidt",
"IsOtherOwner": true,
"URL": "https://paliad.de/deadlines/a",
},
},
"OpenTotal": 1,
"DueToday": []map[string]any{
{
"DueDate": "2026-04-28",
@@ -78,14 +71,10 @@ func TestRenderTemplateDeadlineDigest(t *testing.T) {
}
wants := []string{
"Paliad",
"&Uuml;berf&auml;llig", // overdue header (HTML-entity-encoded by the template)
"DRINGEND", // evening framing on the due_today section
"Schon überfällig", // row title (passed through as data, not entity-encoded)
"Heute fällig", // row title
"2026/0001", "2026/0002",
"Acme v Widget", "Acme v Gadget",
"Anna Schmidt", // OwnerName label rendered for IsOtherOwner row
"https://paliad.de/deadlines/a", // overdue link
"DRINGEND", // evening framing on the due_today section
"Heute fällig", // row title (passed through as data)
"2026/0002",
"Acme v Gadget",
"https://paliad.de/deadlines/b", // due_today link
"https://paliad.de/deadlines", // CTA
}
@@ -94,24 +83,68 @@ func TestRenderTemplateDeadlineDigest(t *testing.T) {
t.Errorf("rendered html missing %q", want)
}
}
// The "Self" owner name should NOT appear because IsOtherOwner=false
// suppresses the owner line.
// "Self" owner name should NOT appear because IsOtherOwner=false suppresses
// the owner line.
if strings.Contains(html, "Self") {
t.Errorf("rendered html should not show OwnerName when IsOtherOwner=false: %q", html)
}
// The DE evening, no-overdue, due_today=1 path should render
// "DRINGEND — 1 heute noch offen".
wantSubject := "[Paliad] DRINGEND — 1 heute noch offen"
if subject != wantSubject {
t.Errorf("subject got %q, want %q", subject, wantSubject)
}
}
// TestRenderTemplateDeadlineDigestSystemausfall covers the worst-case
// subject: evening slot with overdue rows. Must produce SYSTEMAUSFALL
// framing in DE (and SYSTEM FAILURE in EN — covered alongside).
func TestRenderTemplateDeadlineDigestSystemausfall(t *testing.T) {
svc, _ := NewMailService()
for _, tc := range []struct {
name string
lang string
wantSubject string
}{
{"de evening overdue", "de", "[Paliad] SYSTEMAUSFALL: 2 überfällig — plus 1 heute offen"},
{"en evening overdue", "en", "[Paliad] SYSTEM FAILURE: 2 overdue — plus 1 still open today"},
} {
t.Run(tc.name, func(t *testing.T) {
subject, _, err := svc.RenderTemplate(TemplateData{
Lang: tc.lang,
Name: "deadline_digest",
Data: map[string]any{
"Slot": "evening",
"IsEvening": true,
"OverdueCount": 2,
"DueTodayCount": 1,
"DueWarningCount": 0,
"OpenTotal": 1,
"Overdue": []map[string]any{{}, {}},
"DueToday": []map[string]any{{}},
"DeadlinesURL": "https://paliad.de/deadlines",
},
})
if err != nil {
t.Fatalf("RenderTemplate: %v", err)
}
if subject != tc.wantSubject {
t.Errorf("subject got %q, want %q", subject, tc.wantSubject)
}
})
}
}
// TestRenderTemplateInvitation covers the invitation template so a typo in
// invitation.html would fail CI.
// invitation.en.html would fail CI.
func TestRenderTemplateInvitation(t *testing.T) {
svc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
html, err := svc.RenderTemplate(TemplateData{
Subject: "[Paliad] Anna Schmidt lädt Sie ein",
Lang: "en",
Name: "invitation",
subject, html, err := svc.RenderTemplate(TemplateData{
Lang: "en",
Name: "invitation",
Data: map[string]any{
"InviterName": "Anna Schmidt",
"InviterEmail": "anna@hlc.com",
@@ -135,6 +168,9 @@ func TestRenderTemplateInvitation(t *testing.T) {
t.Errorf("rendered html missing %q", want)
}
}
if subject != "[Paliad] Anna Schmidt invites you to Paliad" {
t.Errorf("subject got %q", subject)
}
}
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure

View File

@@ -533,66 +533,27 @@ func (s *ReminderService) deliverDigest(u models.User, slot string, rows []diges
}
}
subject := buildDigestSubject(slot, lang, len(overdue), len(dueToday), len(dueWarning))
// Subject is rendered from the (deadline_digest, lang) template row by
// MailService — see internal/services/email_template_service.go for the
// SYSTEMAUSFALL/SYSTEM FAILURE conditional logic and the rationale for
// keeping that framing in place. OpenTotal is precomputed for the
// "Frist-Erinnerung: N offen" pluralisation in the subject template.
data := map[string]any{
"Slot": slot,
"IsEvening": slot == "evening",
"Overdue": overdue,
"DueToday": dueToday,
"DueWarning": dueWarning,
"OverdueCount": len(overdue),
"DueTodayCount": len(dueToday),
"DueWarningCount": len(dueWarning),
"DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL),
"Slot": slot,
"IsEvening": slot == "evening",
"Overdue": overdue,
"DueToday": dueToday,
"DueWarning": dueWarning,
"OverdueCount": len(overdue),
"DueTodayCount": len(dueToday),
"DueWarningCount": len(dueWarning),
"OpenTotal": len(dueToday) + len(dueWarning),
"DeadlinesURL": fmt.Sprintf("%s/deadlines", s.baseURL),
}
return s.mail.SendTemplate(TemplateData{
To: u.Email,
Subject: subject,
Lang: lang,
Name: "deadline_digest",
Data: data,
To: u.Email,
Lang: lang,
Name: "deadline_digest",
Data: data,
})
}
// buildDigestSubject shapes the email subject. Overdue presence dominates
// the framing — the SLO is "no overdues, ever", so the subject must shout
// when one shows up. Evening slot uses DRINGEND when there's still a
// due-today row; SYSTEMAUSFALL when the day already broke.
func buildDigestSubject(slot, lang string, overdue, dueToday, dueWarning int) string {
if lang == "en" {
switch {
case slot == "evening" && overdue > 0:
return fmt.Sprintf("[Paliad] SYSTEM FAILURE: %d overdue — plus %d still open today", overdue, dueToday)
case slot == "evening" && dueToday > 0:
return fmt.Sprintf("[Paliad] URGENT — %d still open today", dueToday)
case slot == "evening":
return fmt.Sprintf("[Paliad] %d overdue", overdue)
case overdue > 0:
return fmt.Sprintf("[Paliad] OVERDUE: %d — plus %d more", overdue, dueToday+dueWarning)
default:
n := dueToday + dueWarning
if n == 1 {
return "[Paliad] Deadline reminder: 1 open"
}
return fmt.Sprintf("[Paliad] Deadline reminder: %d open", n)
}
}
// German (default).
switch {
case slot == "evening" && overdue > 0:
return fmt.Sprintf("[Paliad] SYSTEMAUSFALL: %d überfällig — plus %d heute offen", overdue, dueToday)
case slot == "evening" && dueToday > 0:
return fmt.Sprintf("[Paliad] DRINGEND — %d heute noch offen", dueToday)
case slot == "evening":
return fmt.Sprintf("[Paliad] %d überfällig", overdue)
case overdue > 0:
return fmt.Sprintf("[Paliad] ÜBERFÄLLIG: %d — plus %d weitere", overdue, dueToday+dueWarning)
default:
n := dueToday + dueWarning
if n == 1 {
return "[Paliad] Frist-Erinnerung: 1 offen"
}
return fmt.Sprintf("[Paliad] Frist-Erinnerung: %d offen", n)
}
}

View File

@@ -222,15 +222,18 @@ func TestVisibleForCategory(t *testing.T) {
}
}
// TestBuildDigestSubject locks the subject-line ladder. Overdue presence
// TestDigestSubjectTemplate locks the subject-line ladder. Overdue presence
// must promote the framing to ÜBERFÄLLIG / SYSTEMAUSFALL — the SLO is
// "no overdues, ever", so the inbox should reflect that.
func TestBuildDigestSubject(t *testing.T) {
// "no overdues, ever", so the inbox should reflect that. Drives the
// embedded subject template via MailService.RenderTemplate so a future
// admin who edits the template can't silently soften the framing without
// failing this test.
func TestDigestSubjectTemplate(t *testing.T) {
cases := []struct {
name string
slot, lang string
overdue, dueToday, dueWarning int
wantContains []string
name string
slot, lang string
overdue, dueToday, dueWarning int
wantContains []string
}{
{"DE morning quiet", "morning", "de", 0, 0, 1, []string{"Frist-Erinnerung", "1 offen"}},
{"DE morning many", "morning", "de", 0, 2, 1, []string{"3 offen"}},
@@ -242,18 +245,51 @@ func TestBuildDigestSubject(t *testing.T) {
{"EN evening overdue", "evening", "en", 1, 1, 0, []string{"SYSTEM FAILURE", "1 overdue"}},
{"EN evening due_today", "evening", "en", 0, 1, 0, []string{"URGENT", "1 still open"}},
}
svc, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := buildDigestSubject(tc.slot, tc.lang, tc.overdue, tc.dueToday, tc.dueWarning)
subj, _, rerr := svc.RenderTemplate(TemplateData{
Lang: tc.lang,
Name: "deadline_digest",
Data: map[string]any{
"Slot": tc.slot,
"IsEvening": tc.slot == "evening",
"OverdueCount": tc.overdue,
"DueTodayCount": tc.dueToday,
"DueWarningCount": tc.dueWarning,
"OpenTotal": tc.dueToday + tc.dueWarning,
// Body needs the slices to render the section tables, but
// subject only reads the *Count fields. Pass empty slices
// of the right length so html/template's range works.
"Overdue": placeholderRows(tc.overdue),
"DueToday": placeholderRows(tc.dueToday),
"DueWarning": placeholderRows(tc.dueWarning),
"DeadlinesURL": "https://paliad.de/deadlines",
},
})
if rerr != nil {
t.Fatalf("RenderTemplate: %v", rerr)
}
for _, s := range tc.wantContains {
if !contains(got, s) {
t.Errorf("buildDigestSubject(...) = %q, want substring %q", got, s)
if !contains(subj, s) {
t.Errorf("subject = %q, want substring %q", subj, s)
}
}
})
}
}
func placeholderRows(n int) []map[string]any {
out := make([]map[string]any, n)
for i := range out {
out[i] = map[string]any{}
}
return out
}
func contains(haystack, needle string) bool {
return len(needle) == 0 || (len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0)
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{.Lang}}">
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Subject}}</title>
</head>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#1c1917;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f5f5f4;padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="560" cellpadding="0" cellspacing="0" border="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<tr>
<td style="background:#BFF355;padding:20px 28px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td align="left" style="font-size:20px;font-weight:700;color:#1c1917;letter-spacing:-0.01em;">
<span style="display:inline-block;width:28px;height:28px;background:#1c1917;color:#BFF355;border-radius:6px;text-align:center;line-height:28px;font-weight:800;vertical-align:middle;margin-right:10px;">p</span>
<span style="vertical-align:middle;">Paliad</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:32px 28px;font-size:15px;line-height:1.55;color:#1c1917;">
{{block "content" .}}{{end}}
</td>
</tr>
<tr>
<td style="padding:18px 28px;border-top:1px solid #e7e5e4;font-size:12px;color:#78716c;text-align:center;">
Paliad &mdash; <a href="https://paliad.de" style="color:#78716c;text-decoration:none;">paliad.de</a>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -2,33 +2,23 @@
{{if .Overdue}}
<h1 style="margin:0 0 12px 0;font-size:20px;line-height:1.3;color:#b91c1c;">
{{if eq .Lang "en"}}Overdue ({{.OverdueCount}}){{else}}&Uuml;berf&auml;llig ({{.OverdueCount}}){{end}}
&Uuml;berf&auml;llig ({{.OverdueCount}})
</h1>
<p style="margin:0 0 12px 0;color:#7f1d1d;font-weight:600;">
{{if eq .Lang "en"}}
System failure: these deadlines were not completed in time. The escalation channel has been notified.
{{else}}
Systemausfall: diese Fristen wurden nicht rechtzeitig erledigt. Der Eskalations&shy;kontakt wurde informiert.
{{end}}
Systemausfall: diese Fristen wurden nicht rechtzeitig erledigt. Der Eskalations&shy;kontakt wurde informiert.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;border:1px solid #fecaca;border-radius:6px;border-collapse:separate;border-spacing:0;">
<tr style="background:#fef2f2;">
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">
{{if eq .Lang "en"}}Due{{else}}F&auml;llig{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">
{{if eq .Lang "en"}}Title{{else}}Titel{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">
{{if eq .Lang "en"}}Matter{{else}}Akte{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">F&auml;llig</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">Titel</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">Akte</th>
</tr>
{{range .Overdue}}
<tr>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fee2e2;white-space:nowrap;color:#b91c1c;font-weight:600;">{{.DueDate}}</td>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fee2e2;">
<a href="{{.URL}}" style="color:#1c1917;text-decoration:none;font-weight:500;">{{.Title}}</a>
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">{{if eq $.Lang "en"}}Owner:{{else}}Eigent&uuml;mer:{{end}} {{.OwnerName}}</div>{{end}}
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Eigent&uuml;mer: {{.OwnerName}}</div>{{end}}
</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #fee2e2;">
{{.ProjectReference}}{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
@@ -40,30 +30,20 @@
{{if .DueToday}}
<h2 style="margin:0 0 12px 0;font-size:18px;line-height:1.3;color:#b45309;">
{{if .IsEvening}}
{{if eq .Lang "en"}}URGENT &mdash; still open today ({{.DueTodayCount}}){{else}}DRINGEND &mdash; heute noch offen ({{.DueTodayCount}}){{end}}
{{else}}
{{if eq .Lang "en"}}Due today ({{.DueTodayCount}}){{else}}Heute f&auml;llig ({{.DueTodayCount}}){{end}}
{{end}}
{{if .IsEvening}}DRINGEND &mdash; heute noch offen ({{.DueTodayCount}}){{else}}Heute f&auml;llig ({{.DueTodayCount}}){{end}}
</h2>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;border:1px solid #fde68a;border-radius:6px;border-collapse:separate;border-spacing:0;">
<tr style="background:#fffbeb;">
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">
{{if eq .Lang "en"}}Due{{else}}F&auml;llig{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">
{{if eq .Lang "en"}}Title{{else}}Titel{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">
{{if eq .Lang "en"}}Matter{{else}}Akte{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">F&auml;llig</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">Titel</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">Akte</th>
</tr>
{{range .DueToday}}
<tr>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fef3c7;white-space:nowrap;">{{.DueDate}}</td>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fef3c7;">
<a href="{{.URL}}" style="color:#1c1917;text-decoration:none;font-weight:500;">{{.Title}}</a>
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">{{if eq $.Lang "en"}}Owner:{{else}}Eigent&uuml;mer:{{end}} {{.OwnerName}}</div>{{end}}
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Eigent&uuml;mer: {{.OwnerName}}</div>{{end}}
</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #fef3c7;">
{{.ProjectReference}}{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
@@ -75,26 +55,20 @@
{{if .DueWarning}}
<h2 style="margin:0 0 12px 0;font-size:18px;line-height:1.3;color:#1c1917;">
{{if eq .Lang "en"}}Due in one week ({{.DueWarningCount}}){{else}}In einer Woche f&auml;llig ({{.DueWarningCount}}){{end}}
In einer Woche f&auml;llig ({{.DueWarningCount}})
</h2>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;border:1px solid #e7e5e4;border-radius:6px;border-collapse:separate;border-spacing:0;">
<tr style="background:#f5f5f4;">
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">
{{if eq .Lang "en"}}Due{{else}}F&auml;llig{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">
{{if eq .Lang "en"}}Title{{else}}Titel{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">
{{if eq .Lang "en"}}Matter{{else}}Akte{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">F&auml;llig</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">Titel</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">Akte</th>
</tr>
{{range .DueWarning}}
<tr>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #f5f5f4;white-space:nowrap;">{{.DueDate}}</td>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #f5f5f4;">
<a href="{{.URL}}" style="color:#1c1917;text-decoration:none;font-weight:500;">{{.Title}}</a>
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">{{if eq $.Lang "en"}}Owner:{{else}}Eigent&uuml;mer:{{end}} {{.OwnerName}}</div>{{end}}
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Eigent&uuml;mer: {{.OwnerName}}</div>{{end}}
</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #f5f5f4;">
{{.ProjectReference}}{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
@@ -106,7 +80,7 @@
<p style="margin:24px 0 0 0;">
<a href="{{.DeadlinesURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
{{if eq .Lang "en"}}All deadlines{{else}}Alle Fristen{{end}}
Alle Fristen
</a>
</p>
{{end}}

View File

@@ -0,0 +1,86 @@
{{define "content"}}
{{if .Overdue}}
<h1 style="margin:0 0 12px 0;font-size:20px;line-height:1.3;color:#b91c1c;">
Overdue ({{.OverdueCount}})
</h1>
<p style="margin:0 0 12px 0;color:#7f1d1d;font-weight:600;">
System failure: these deadlines were not completed in time. The escalation channel has been notified.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;border:1px solid #fecaca;border-radius:6px;border-collapse:separate;border-spacing:0;">
<tr style="background:#fef2f2;">
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">Due</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">Title</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#7f1d1d;font-weight:600;border-bottom:1px solid #fecaca;">Matter</th>
</tr>
{{range .Overdue}}
<tr>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fee2e2;white-space:nowrap;color:#b91c1c;font-weight:600;">{{.DueDate}}</td>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fee2e2;">
<a href="{{.URL}}" style="color:#1c1917;text-decoration:none;font-weight:500;">{{.Title}}</a>
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Owner: {{.OwnerName}}</div>{{end}}
</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #fee2e2;">
{{.ProjectReference}}{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
</td>
</tr>
{{end}}
</table>
{{end}}
{{if .DueToday}}
<h2 style="margin:0 0 12px 0;font-size:18px;line-height:1.3;color:#b45309;">
{{if .IsEvening}}URGENT &mdash; still open today ({{.DueTodayCount}}){{else}}Due today ({{.DueTodayCount}}){{end}}
</h2>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;border:1px solid #fde68a;border-radius:6px;border-collapse:separate;border-spacing:0;">
<tr style="background:#fffbeb;">
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">Due</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">Title</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#92400e;font-weight:600;border-bottom:1px solid #fde68a;">Matter</th>
</tr>
{{range .DueToday}}
<tr>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fef3c7;white-space:nowrap;">{{.DueDate}}</td>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #fef3c7;">
<a href="{{.URL}}" style="color:#1c1917;text-decoration:none;font-weight:500;">{{.Title}}</a>
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Owner: {{.OwnerName}}</div>{{end}}
</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #fef3c7;">
{{.ProjectReference}}{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
</td>
</tr>
{{end}}
</table>
{{end}}
{{if .DueWarning}}
<h2 style="margin:0 0 12px 0;font-size:18px;line-height:1.3;color:#1c1917;">
Due in one week ({{.DueWarningCount}})
</h2>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px 0;border:1px solid #e7e5e4;border-radius:6px;border-collapse:separate;border-spacing:0;">
<tr style="background:#f5f5f4;">
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">Due</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">Title</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">Matter</th>
</tr>
{{range .DueWarning}}
<tr>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #f5f5f4;white-space:nowrap;">{{.DueDate}}</td>
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #f5f5f4;">
<a href="{{.URL}}" style="color:#1c1917;text-decoration:none;font-weight:500;">{{.Title}}</a>
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Owner: {{.OwnerName}}</div>{{end}}
</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #f5f5f4;">
{{.ProjectReference}}{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
</td>
</tr>
{{end}}
</table>
{{end}}
<p style="margin:24px 0 0 0;">
<a href="{{.DeadlinesURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
All deadlines
</a>
</p>
{{end}}

View File

@@ -0,0 +1,14 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">{{.InviterName}} l&auml;dt Sie zu Paliad ein</h1>
<p style="margin:0 0 12px 0;">Paliad ist die Patent-Praxis-Plattform f&uuml;r {{.Firm}} &mdash; Aktenverwaltung, Fristenberechnung, Wissenswerkzeuge und mehr.</p>
{{if .Message}}
<div style="background:#f5f5f4;border-left:3px solid #BFF355;padding:14px 16px;margin:20px 0;border-radius:0 6px 6px 0;font-size:14px;line-height:1.55;color:#44403c;white-space:pre-wrap;">{{.Message}}</div>
{{end}}
<p style="margin:20px 0 24px 0;">Registrieren Sie sich mit Ihrer {{.Firm}}-E-Mail-Adresse:</p>
<p style="margin:0;">
<a href="{{.RegisterURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Zu Paliad anmelden
</a>
</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Gesendet an {{.ToEmail}} von {{.InviterEmail}}. Falls Sie diese Einladung nicht erwartet haben, k&ouml;nnen Sie sie ignorieren.</p>
{{end}}

View File

@@ -0,0 +1,14 @@
{{define "content"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">{{.InviterName}} invites you to Paliad</h1>
<p style="margin:0 0 12px 0;">Paliad is the patent practice platform for {{.Firm}} &mdash; matter management, deadline calculations, knowledge tools, and more.</p>
{{if .Message}}
<div style="background:#f5f5f4;border-left:3px solid #BFF355;padding:14px 16px;margin:20px 0;border-radius:0 6px 6px 0;font-size:14px;line-height:1.55;color:#44403c;white-space:pre-wrap;">{{.Message}}</div>
{{end}}
<p style="margin:20px 0 24px 0;">Sign up with your {{.Firm}} email to get started:</p>
<p style="margin:0;">
<a href="{{.RegisterURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Join Paliad
</a>
</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Sent to {{.ToEmail}} by {{.InviterEmail}}. If you didn't expect this invitation, you can ignore it.</p>
{{end}}

View File

@@ -1,29 +0,0 @@
{{define "content"}}
{{if eq .Lang "en"}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">{{.InviterName}} invites you to Paliad</h1>
<p style="margin:0 0 12px 0;">Paliad is the patent practice platform for {{.Firm}} &mdash; matter management, deadline calculations, knowledge tools, and more.</p>
{{if .Message}}
<div style="background:#f5f5f4;border-left:3px solid #BFF355;padding:14px 16px;margin:20px 0;border-radius:0 6px 6px 0;font-size:14px;line-height:1.55;color:#44403c;white-space:pre-wrap;">{{.Message}}</div>
{{end}}
<p style="margin:20px 0 24px 0;">Sign up with your {{.Firm}} email to get started:</p>
<p style="margin:0;">
<a href="{{.RegisterURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Join Paliad
</a>
</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Sent to {{.ToEmail}} by {{.InviterEmail}}. If you didn't expect this invitation, you can ignore it.</p>
{{else}}
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">{{.InviterName}} l&auml;dt Sie zu Paliad ein</h1>
<p style="margin:0 0 12px 0;">Paliad ist die Patent-Praxis-Plattform f&uuml;r {{.Firm}} &mdash; Aktenverwaltung, Fristenberechnung, Wissenswerkzeuge und mehr.</p>
{{if .Message}}
<div style="background:#f5f5f4;border-left:3px solid #BFF355;padding:14px 16px;margin:20px 0;border-radius:0 6px 6px 0;font-size:14px;line-height:1.55;color:#44403c;white-space:pre-wrap;">{{.Message}}</div>
{{end}}
<p style="margin:20px 0 24px 0;">Registrieren Sie sich mit Ihrer {{.Firm}}-E-Mail-Adresse:</p>
<p style="margin:0;">
<a href="{{.RegisterURL}}" style="display:inline-block;background:#1c1917;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:600;font-size:14px;">
Zu Paliad anmelden
</a>
</p>
<p style="margin:24px 0 0 0;font-size:12px;color:#78716c;">Gesendet an {{.ToEmail}} von {{.InviterEmail}}. Falls Sie diese Einladung nicht erwartet haben, k&ouml;nnen Sie sie ignorieren.</p>
{{end}}
{{end}}