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:
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">×</button>
|
||||
</div>
|
||||
<p data-i18n="invite.modal.body" className="invite-modal-body">
|
||||
Senden Sie eine Einladung an eine HLC-E-Mail-Adresse. Die Empfänger:in erhä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ö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ür die Aktenverwaltung — 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user