feat: email service — SMTP + deadline reminders + invitations (t-paliad-021)

- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
  html/template rendering, branded base layout + content templates, silent
  no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
  overdue / due tomorrow / due within the week (Monday digest). Dedup via
  paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
  whitelist, in-memory 10/day/user rate limit, audit row in
  paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
  question; current pilot keeps identity mails on Supabase default sender.

Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.
This commit is contained in:
m
2026-04-20 12:34:38 +02:00
parent 45c7cf34ef
commit 11217f7bfa
26 changed files with 1808 additions and 12 deletions

View File

@@ -686,6 +686,17 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.termine": "Termine",
"nav.group.einstellungen": "Einstellungen",
"nav.caldav": "CalDAV",
// Invitation modal (sidebar)
"invite.button": "Kolleg:in einladen",
"invite.modal.title": "Kolleg:in zu Paliad einladen",
"invite.modal.body": "Senden Sie eine Einladung an eine HLC-E-Mail-Adresse. Die Empf\u00e4nger:in erh\u00e4lt einen Registrierungslink.",
"invite.modal.email": "E-Mail-Adresse",
"invite.modal.message": "Pers\u00f6nliche Nachricht (optional)",
"invite.modal.message.placeholder": "Hi, ich nutze Paliad f\u00fcr die Aktenverwaltung \u2014 schau es dir mal an.",
"invite.modal.cancel": "Abbrechen",
"invite.modal.send": "Einladung senden",
"termine.list.title": "Termine \u2014 Paliad",
"termine.list.heading": "Termine",
"termine.list.subtitle": "Verhandlungen, Besprechungen, Beratungen \u2014 pers\u00f6nlich oder akten-bezogen.",
@@ -1484,6 +1495,17 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.termine": "Appointments",
"nav.group.einstellungen": "Settings",
"nav.caldav": "CalDAV",
// Invitation modal (sidebar)
"invite.button": "Invite a colleague",
"invite.modal.title": "Invite a colleague to Paliad",
"invite.modal.body": "Send an invitation to an HLC email address. The recipient will receive a registration link.",
"invite.modal.email": "Email address",
"invite.modal.message": "Personal message (optional)",
"invite.modal.message.placeholder": "Hi, I'm using Paliad for matter management \u2014 take a look.",
"invite.modal.cancel": "Cancel",
"invite.modal.send": "Send invitation",
"termine.list.title": "Appointments \u2014 Paliad",
"termine.list.heading": "Appointments",
"termine.list.subtitle": "Hearings, meetings, consultations \u2014 personal or matter-linked.",

View File

@@ -18,6 +18,7 @@ function migrateLegacyPinKey(): void {
export function initSidebar() {
migrateLegacyPinKey();
initInviteModal();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
@@ -121,3 +122,106 @@ export function initSidebar() {
}
});
}
// Invitation modal — opened from the sidebar "Kolleg:in einladen" button.
// Keeps the whole flow client-side: validates, POSTs to /api/invite, shows
// success or the server's error message in the same modal. Kept inside
// sidebar.ts because the Sidebar component owns the modal markup — every
// page that renders <Sidebar /> picks up the behaviour for free.
function initInviteModal(): void {
const btn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
const modal = document.getElementById("invite-modal") as HTMLElement | null;
const closeBtn = document.getElementById("invite-modal-close") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("invite-modal-cancel") as HTMLButtonElement | null;
const form = document.getElementById("invite-form") as HTMLFormElement | null;
const emailInput = document.getElementById("invite-email") as HTMLInputElement | null;
const messageInput = document.getElementById("invite-message") as HTMLTextAreaElement | null;
const submitBtn = document.getElementById("invite-submit") as HTMLButtonElement | null;
const feedback = document.getElementById("invite-feedback") as HTMLElement | null;
if (!btn || !modal || !form || !emailInput || !submitBtn || !feedback) return;
function open(): void {
clearFeedback();
modal!.style.display = "flex";
setTimeout(() => emailInput!.focus(), 30);
}
function close(): void {
modal!.style.display = "none";
form!.reset();
clearFeedback();
}
function clearFeedback(): void {
feedback!.style.display = "none";
feedback!.textContent = "";
feedback!.classList.remove("form-msg-success", "form-msg-error");
}
function setFeedback(kind: "success" | "error", text: string): void {
feedback!.textContent = text;
feedback!.classList.remove("form-msg-success", "form-msg-error");
feedback!.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
feedback!.style.display = "block";
}
btn.addEventListener("click", (e) => {
e.preventDefault();
open();
});
closeBtn?.addEventListener("click", close);
cancelBtn?.addEventListener("click", close);
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display !== "none") close();
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = emailInput.value.trim();
const message = messageInput?.value.trim() ?? "";
if (!email) return;
submitBtn.disabled = true;
clearFeedback();
try {
const res = await fetch("/api/invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, message }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
const remaining = typeof data.remaining_today === "number" ? data.remaining_today : null;
const baseMsg = (document.documentElement.lang === "en")
? `Invitation sent to ${email}.`
: `Einladung gesendet an ${email}.`;
const tail = remaining !== null
? ((document.documentElement.lang === "en")
? ` (${remaining} invitations remaining today.)`
: ` (Noch ${remaining} Einladungen heute m\u00f6glich.)`)
: "";
setFeedback("success", baseMsg + tail);
form.reset();
setTimeout(close, 2500);
} else {
const msg = typeof data.error === "string"
? data.error
: ((document.documentElement.lang === "en")
? "Failed to send invitation."
: "Einladung konnte nicht gesendet werden.");
setFeedback("error", msg);
}
} catch (_err) {
setFeedback("error",
(document.documentElement.lang === "en")
? "Network error — please try again."
: "Netzwerkfehler — bitte erneut versuchen.");
} finally {
submitBtn.disabled = false;
}
});
}

View File

@@ -17,6 +17,7 @@ const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
interface SidebarProps {
currentPath: string;
@@ -106,6 +107,10 @@ export function Sidebar({ currentPath }: SidebarProps): string {
<div className="sidebar-spacer" />
<div className="sidebar-bottom">
<button type="button" className="sidebar-item sidebar-invite-btn" id="sidebar-invite-btn">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<span className="sidebar-label" data-i18n="invite.button">Kolleg:in einladen</span>
</button>
<div className="sidebar-item sidebar-lang-item">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_GLOBE }} />
<span className="sidebar-label">
@@ -127,6 +132,36 @@ export function Sidebar({ currentPath }: SidebarProps): string {
<span dangerouslySetInnerHTML={{ __html: ICON_MENU }} />
</button>
<div className="sidebar-overlay" />
{/* Invitation modal — lives alongside the sidebar so every page can
open it. Hidden by default; sidebar.ts toggles display. */}
<div className="modal-overlay" id="invite-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="invite.modal.title">Kolleg:in zu Paliad einladen</h2>
<button className="modal-close" id="invite-modal-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="invite.modal.body" className="invite-modal-body">
Senden Sie eine Einladung an eine HLC-E-Mail-Adresse. Die Empf&auml;nger:in erh&auml;lt einen Registrierungslink.
</p>
<form id="invite-form" className="akten-form" autocomplete="off">
<div className="form-field">
<label htmlFor="invite-email" data-i18n="invite.modal.email">E-Mail-Adresse</label>
<input type="email" id="invite-email" name="email" required placeholder="kolleg@hlc.com" />
</div>
<div className="form-field">
<label htmlFor="invite-message" data-i18n="invite.modal.message">Pers&ouml;nliche Nachricht (optional)</label>
<textarea id="invite-message" name="message" rows={4} data-i18n-placeholder="invite.modal.message.placeholder"
placeholder="Hi, ich nutze Paliad f&uuml;r die Aktenverwaltung &mdash; schau es dir mal an." />
</div>
<div id="invite-feedback" className="form-msg" style="display:none" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="invite-modal-cancel" data-i18n="invite.modal.cancel">Abbrechen</button>
<button type="submit" className="btn-primary" id="invite-submit" data-i18n="invite.modal.send">Einladung senden</button>
</div>
</form>
</div>
</div>
</Fragment>
);
}

View File

@@ -5334,3 +5334,22 @@ input[type="range"]::-moz-range-thumb {
margin: 0 0 0.75rem 0;
color: var(--color-text);
}
/* --- Invitation modal (sidebar) --- */
.invite-modal-body {
color: var(--color-text-muted);
font-size: 0.9rem;
margin: 0 0 1rem 0;
line-height: 1.5;
}
#invite-modal .modal-card {
max-width: 480px;
}
#invite-modal textarea {
font-family: var(--font-sans);
resize: vertical;
min-height: 5rem;
}