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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
117
frontend/src/admin-email-templates-edit.tsx
Normal file
117
frontend/src/admin-email-templates-edit.tsx
Normal 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 — 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">
|
||||
← 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ü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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/admin-email-templates.tsx
Normal file
56
frontend/src/admin-email-templates.tsx
Normal 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 — 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ü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…</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-email-templates.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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ändert? Nachvollziehbarkeit fü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ür Einladungen, Erinnerungen und Layout anpassen.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
420
frontend/src/client/admin-email-templates-edit.ts
Normal file
420
frontend/src/client/admin-email-templates-edit.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
150
frontend/src/client/admin-email-templates.ts
Normal file
150
frontend/src/client/admin-email-templates.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
2
internal/db/migrations/026_email_templates.down.sql
Normal file
2
internal/db/migrations/026_email_templates.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS paliad.email_template_versions;
|
||||
DROP TABLE IF EXISTS paliad.email_templates;
|
||||
46
internal/db/migrations/026_email_templates.up.sql
Normal file
46
internal/db/migrations/026_email_templates.up.sql
Normal 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;
|
||||
269
internal/handlers/email_templates.go
Normal file
269
internal/handlers/email_templates.go
Normal 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()})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,7 @@ type dbServices struct {
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
audit *services.AuditService
|
||||
emailTemplate *services.EmailTemplateService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
116
internal/services/email_template_samples.go
Normal file
116
internal/services/email_template_samples.go
Normal 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
|
||||
}
|
||||
458
internal/services/email_template_service.go
Normal file
458
internal/services/email_template_service.go
Normal 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 -}}`
|
||||
157
internal/services/email_template_service_test.go
Normal file
157
internal/services/email_template_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
internal/services/email_template_variables.go
Normal file
121
internal/services/email_template_variables.go
Normal 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"},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"Überfä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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
38
internal/templates/email/base.en.html
Normal file
38
internal/templates/email/base.en.html
Normal 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 — <a href="https://paliad.de" style="color:#78716c;text-decoration:none;">paliad.de</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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}}Überfällig ({{.OverdueCount}}){{end}}
|
||||
Überfä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­kontakt wurde informiert.
|
||||
{{end}}
|
||||
Systemausfall: diese Fristen wurden nicht rechtzeitig erledigt. Der Eskalations­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ä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ä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ümer:{{end}} {{.OwnerName}}</div>{{end}}
|
||||
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Eigentümer: {{.OwnerName}}</div>{{end}}
|
||||
</td>
|
||||
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #fee2e2;">
|
||||
{{.ProjectReference}}{{if .ProjectTitle}} — {{.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 — still open today ({{.DueTodayCount}}){{else}}DRINGEND — heute noch offen ({{.DueTodayCount}}){{end}}
|
||||
{{else}}
|
||||
{{if eq .Lang "en"}}Due today ({{.DueTodayCount}}){{else}}Heute fällig ({{.DueTodayCount}}){{end}}
|
||||
{{end}}
|
||||
{{if .IsEvening}}DRINGEND — heute noch offen ({{.DueTodayCount}}){{else}}Heute fä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ä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ä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ümer:{{end}} {{.OwnerName}}</div>{{end}}
|
||||
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Eigentümer: {{.OwnerName}}</div>{{end}}
|
||||
</td>
|
||||
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #fef3c7;">
|
||||
{{.ProjectReference}}{{if .ProjectTitle}} — {{.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ällig ({{.DueWarningCount}}){{end}}
|
||||
In einer Woche fä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ä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ä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ümer:{{end}} {{.OwnerName}}</div>{{end}}
|
||||
{{if .IsOtherOwner}}<div style="font-size:11px;color:#78716c;">Eigentümer: {{.OwnerName}}</div>{{end}}
|
||||
</td>
|
||||
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #f5f5f4;">
|
||||
{{.ProjectReference}}{{if .ProjectTitle}} — {{.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}}
|
||||
86
internal/templates/email/deadline_digest.en.html
Normal file
86
internal/templates/email/deadline_digest.en.html
Normal 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}} — {{.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 — 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}} — {{.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}} — {{.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}}
|
||||
14
internal/templates/email/invitation.de.html
Normal file
14
internal/templates/email/invitation.de.html
Normal 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ädt Sie zu Paliad ein</h1>
|
||||
<p style="margin:0 0 12px 0;">Paliad ist die Patent-Praxis-Plattform für {{.Firm}} — 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önnen Sie sie ignorieren.</p>
|
||||
{{end}}
|
||||
14
internal/templates/email/invitation.en.html
Normal file
14
internal/templates/email/invitation.en.html
Normal 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}} — 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}}
|
||||
@@ -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}} — 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ädt Sie zu Paliad ein</h1>
|
||||
<p style="margin:0 0 12px 0;">Paliad ist die Patent-Praxis-Plattform für {{.Firm}} — 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önnen Sie sie ignorieren.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user