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:
@@ -44,6 +44,8 @@ Paliad — the patent paladin. All-in-one patent practice platform for HLC (form
|
||||
| `DATABASE_URL` | for Aktenverwaltung | Direct Postgres conn for migrations + Akten/Fristen/Termine services. Knowledge-platform features (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) work without it — those endpoints return data from static JSON and never touch the pool. Aktenverwaltung endpoints return 503 if unset. |
|
||||
| `CALDAV_ENCRYPTION_KEY` | for CalDAV sync | 32-byte AES-256 key, base64-encoded. Encrypts CalDAV passwords at rest (AES-GCM). Server fails fast on malformed key; CalDAV is silently disabled if unset (Termine still work locally; `/api/caldav-config` returns 501). |
|
||||
| `GITEA_TOKEN` | optional | Gitea API token for the private file proxy (Downloads) |
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion) which is deferred per m's 2026-04-16 decision. Do not set. |
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -38,6 +38,18 @@ func main() {
|
||||
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
|
||||
}
|
||||
|
||||
// MailService is wired regardless of DB availability — it no-ops when
|
||||
// SMTP env vars are unset, so the server stays runnable for knowledge-
|
||||
// platform-only deployments. Template-parse errors at boot are fatal.
|
||||
mailSvc, err := services.NewMailService()
|
||||
if err != nil {
|
||||
log.Fatalf("mail service init: %v", err)
|
||||
}
|
||||
|
||||
// Shared context for background goroutines (CalDAV sync + reminder job).
|
||||
bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// DATABASE_URL is optional during the Phase A → Phase D transition. The
|
||||
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
|
||||
// without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set.
|
||||
@@ -81,6 +93,10 @@ func main() {
|
||||
// calendar without waiting for the next 60-second tick.
|
||||
terminSvc.SetCalDAVPusher(caldavSvc)
|
||||
|
||||
baseURL := os.Getenv("PALIAD_BASE_URL")
|
||||
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
||||
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
|
||||
|
||||
svcBundle = &handlers.Services{
|
||||
Akte: akteSvc,
|
||||
Parteien: services.NewParteienService(pool, akteSvc),
|
||||
@@ -94,19 +110,21 @@ func main() {
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Notiz: services.NewNotizService(pool, akteSvc, terminSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, akteSvc),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
}
|
||||
log.Println("Phase B services initialised")
|
||||
|
||||
// Spawn one CalDAV sync goroutine per enabled user. No-op if cipher
|
||||
// is nil. Lives for the process lifetime; signal handler cleans up.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := caldavSvc.Start(ctx); err != nil {
|
||||
// Spawn background goroutines: CalDAV sync (one per enabled user)
|
||||
// and the hourly reminder scanner. Both live for the process
|
||||
// lifetime; the signal-scoped context cleans them up on SIGTERM.
|
||||
if err := caldavSvc.Start(bgCtx); err != nil {
|
||||
log.Printf("CalDAV start: %v", err)
|
||||
}
|
||||
reminderSvc.Start(bgCtx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
log.Println("CalDAV: shutdown signal received")
|
||||
<-bgCtx.Done()
|
||||
log.Println("background services: shutdown signal received")
|
||||
caldavSvc.Stop()
|
||||
}()
|
||||
} else {
|
||||
|
||||
@@ -12,5 +12,13 @@ services:
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
|
||||
- ALLOWED_EMAIL_DOMAINS=${ALLOWED_EMAIL_DOMAINS}
|
||||
- PALIAD_BASE_URL=${PALIAD_BASE_URL}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USERNAME=${SMTP_USERNAME}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_FROM=${SMTP_FROM}
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME}
|
||||
- SMTP_USE_TLS=${SMTP_USE_TLS}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -294,3 +294,9 @@ What Paliad is *not*:
|
||||
- **External counsel access.** Bringing in an outside boutique on a specific Akte currently means adding them as a user (not possible without the HLC email domain). A future `external_collaborators` table with scoped RLS would cover it.
|
||||
- **Read-only archive post-closure.** Add `is_archived` on `paliad.akten`, deny mutations via RLS. Cheap follow-on.
|
||||
- **AI revisit.** The Phase H / 4.1 pause is a decision, not a technical block. When Anthropic API goes back on the table, both AI extraction (Phase H) and KI-Recherche (4.1) can be unblocked.
|
||||
- **Supabase Auth SMTP routing.** Confirmation / password-reset / magic-link mails from `ydb.youpc.org` still go through Supabase's default sender. Routing them through Paliad's SMTP (`mail@paliad.de`) is a one-line GoTrue config change, but youpc's Supabase is shared with youpc.org, so the global SMTP settings can't be flipped without rebranding youpc.org's auth mails too. Resolution paths (lowest-effort first):
|
||||
1. Move Paliad to its own Supabase project and configure SMTP there.
|
||||
2. Wait until the youpc instance exposes per-project SMTP (Supabase Pro / self-hosted upgrade).
|
||||
3. Write a custom GoTrue webhook that Paliad's Go server intercepts and re-sends via `MailService`.
|
||||
|
||||
For now the inbox-facing mails (reminders + invitations) go through Paliad's SMTP; identity-bootstrap mails stay on the default sender — acceptable for the current HLC pilot. Tracked as part of t-paliad-021 completion (2026-04-20).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
internal/db/migrations/016_email_tables.down.sql
Normal file
5
internal/db/migrations/016_email_tables.down.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
DROP TABLE IF EXISTS paliad.invitations;
|
||||
DROP TABLE IF EXISTS paliad.reminder_log;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP COLUMN IF EXISTS lang;
|
||||
61
internal/db/migrations/016_email_tables.up.sql
Normal file
61
internal/db/migrations/016_email_tables.up.sql
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Phase M (t-paliad-021): email service tables.
|
||||
--
|
||||
-- Two tables in this migration:
|
||||
-- * reminder_log — dedup for hourly deadline-reminder emails. One row per
|
||||
-- (frist_id, reminder_type, day). The service refuses to re-send when a
|
||||
-- row younger than 24h exists; storing the timestamp rather than a bare
|
||||
-- (frist_id, type) PK lets us re-send after the dedup window without
|
||||
-- garbage-collecting.
|
||||
-- * invitations — append-only audit of colleague invites sent via POST
|
||||
-- /api/invite. Lets us show per-user history and, later, mark a row
|
||||
-- accepted_at when the invitee completes register.
|
||||
--
|
||||
-- Optional paliad.users.lang column lets per-user language preference override
|
||||
-- the DE default when rendering reminders/invitations. Unset today (the
|
||||
-- onboarding form doesn't collect it yet); the MailService falls back to 'de'
|
||||
-- whenever the field is NULL.
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS lang text;
|
||||
|
||||
-- reminder_log: one row per (frist_id, reminder_type) and day. The type
|
||||
-- column takes 'overdue' | 'tomorrow' | 'weekly'. Weekly rows use the Monday
|
||||
-- key date; per-Frist rows use the due date.
|
||||
CREATE TABLE paliad.reminder_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
frist_id uuid REFERENCES paliad.fristen(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
reminder_type text NOT NULL CHECK (reminder_type IN ('overdue', 'tomorrow', 'weekly')),
|
||||
sent_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Dedup index: fast lookup of "did we send this reminder to this user for
|
||||
-- this frist recently?". For weekly summaries, frist_id is NULL and the
|
||||
-- uniqueness is enforced by the service (one weekly per user per Monday).
|
||||
CREATE INDEX reminder_log_dedup_idx
|
||||
ON paliad.reminder_log (user_id, reminder_type, frist_id, sent_at DESC);
|
||||
|
||||
-- invitations: record of every /api/invite call. Rate-limited per user at
|
||||
-- the handler layer (10/day), not via a DB constraint — the handler keeps
|
||||
-- an in-memory counter, mirroring the AI-extraction rate limiter pattern.
|
||||
CREATE TABLE paliad.invitations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
from_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
to_email text NOT NULL,
|
||||
message text NOT NULL DEFAULT '',
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
accepted_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX invitations_from_user_sent_idx
|
||||
ON paliad.invitations (from_user_id, sent_at DESC);
|
||||
|
||||
CREATE INDEX invitations_to_email_idx
|
||||
ON paliad.invitations (lower(to_email));
|
||||
|
||||
-- RLS: service-layer only for now (no client-facing endpoints read these
|
||||
-- tables). Enabling RLS with no policies denies all direct Supabase PostgREST
|
||||
-- access — the Go server bypasses RLS via its direct DB pool anyway, matching
|
||||
-- every other paliad.* write-table.
|
||||
ALTER TABLE paliad.reminder_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.invitations ENABLE ROW LEVEL SECURITY;
|
||||
@@ -26,6 +26,8 @@ type dbServices struct {
|
||||
dashboard *services.DashboardService
|
||||
notiz *services.NotizService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
@@ -120,7 +120,7 @@ func isAllowedEmailDomain(email string) bool {
|
||||
return false
|
||||
}
|
||||
domain := strings.ToLower(parts[1])
|
||||
for _, allowed := range allowedEmailDomains() {
|
||||
for _, allowed := range AllowedEmailDomains() {
|
||||
if domain == allowed {
|
||||
return true
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func isAllowedEmailDomain(email string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func allowedEmailDomains() []string {
|
||||
func AllowedEmailDomains() []string {
|
||||
raw := os.Getenv("ALLOWED_EMAIL_DOMAINS")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{"hoganlovells.com", "hlc.com", "hlc.de"}
|
||||
|
||||
@@ -25,6 +25,8 @@ type Services struct {
|
||||
Dashboard *services.DashboardService
|
||||
Notiz *services.NotizService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
Mail *services.MailService
|
||||
Invite *services.InviteService
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {
|
||||
@@ -45,6 +47,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
dashboard: svc.Dashboard,
|
||||
notiz: svc.Notiz,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
mail: svc.Mail,
|
||||
invite: svc.Invite,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +163,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/offices", handleListOffices)
|
||||
protected.HandleFunc("GET /api/dashboard", handleDashboardAPI)
|
||||
|
||||
// Invitations — send a colleague a Paliad invite email.
|
||||
protected.HandleFunc("POST /api/invite", handleInvite)
|
||||
protected.HandleFunc("GET /api/invite", handleInviteStatus)
|
||||
|
||||
// First-login profile capture — authenticated but NOT behind the
|
||||
// onboarding gate (it's the one page a user without paliad.users may reach).
|
||||
protected.HandleFunc("GET /onboarding", handleOnboardingPage)
|
||||
|
||||
127
internal/handlers/invite.go
Normal file
127
internal/handlers/invite.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
)
|
||||
|
||||
// inviteRequest is the JSON body for POST /api/invite. Both fields are
|
||||
// optional-ish: email is required and validated, message is a free-form
|
||||
// personal note the sender may include. Kept minimal on purpose — the
|
||||
// invitation UX is a single email field plus an optional textarea.
|
||||
type inviteRequest struct {
|
||||
Email string `json:"email"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type inviteResponse struct {
|
||||
ID string `json:"id"`
|
||||
Remaining int `json:"remaining_today"`
|
||||
}
|
||||
|
||||
// POST /api/invite — send a branded invitation email to a colleague.
|
||||
//
|
||||
// Auth: any onboarded user may invite. The HLC email-domain whitelist
|
||||
// (ALLOWED_EMAIL_DOMAINS) is enforced in-service so callers can't bypass it
|
||||
// by bypassing the handler.
|
||||
//
|
||||
// Rate limit: 10 invitations per 24h per user, enforced in InviteService.
|
||||
// The response always includes `remaining_today` so the UI can show
|
||||
// "3 invitations left" without a second request.
|
||||
func handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.invite == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "invitations unavailable — SMTP not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me, err := dbSvc.users.GetByID(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
if me == nil {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "onboarding required before sending invitations",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req inviteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := dbSvc.invite.Send(r.Context(), uid,
|
||||
services.Inviter{
|
||||
DisplayName: me.DisplayName,
|
||||
Email: me.Email,
|
||||
Lang: me.Lang,
|
||||
},
|
||||
services.InviteInput{
|
||||
ToEmail: req.Email,
|
||||
Message: req.Message,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrInviteInvalidEmail):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid email address",
|
||||
})
|
||||
case errors.Is(err, services.ErrInviteDomainBlocked):
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "recipient domain not on the HLC allow-list",
|
||||
})
|
||||
case errors.Is(err, services.ErrInviteRateLimited):
|
||||
w.Header().Set("Retry-After", "3600")
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error": "invitation rate limit reached — try again tomorrow",
|
||||
})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "failed to send invitation",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, inviteResponse{
|
||||
ID: id.String(),
|
||||
Remaining: dbSvc.invite.RemainingToday(uid),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/invite — read-only status: how many invitation slots the caller
|
||||
// has left in the current 24h window. Cheap to call; frontend can poll this
|
||||
// before opening the modal to avoid disabling the send button post-hoc.
|
||||
func handleInviteStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.invite == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "invitations unavailable — SMTP not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{
|
||||
"remaining_today": dbSvc.invite.RemainingToday(uid),
|
||||
"limit": services.InviteRateLimit,
|
||||
})
|
||||
}
|
||||
@@ -21,8 +21,12 @@ type User struct {
|
||||
PracticeGroup *string `db:"practice_group" json:"practice_group,omitempty"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Dezernat *string `db:"dezernat" json:"dezernat,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// Lang is the preferred UI language for transactional email ("de"/"en").
|
||||
// NULL → MailService falls back to German. Not collected at onboarding
|
||||
// today; reserved for a future profile-edit screen.
|
||||
Lang *string `db:"lang" json:"lang,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Akte is a matter (case file). Office-scoped visibility: see paliad.can_see_akte.
|
||||
|
||||
223
internal/services/invite_service.go
Normal file
223
internal/services/invite_service.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Package services — InviteService — colleague invitations.
|
||||
//
|
||||
// Sends one branded invitation email and records the row in
|
||||
// paliad.invitations. Rate limiting lives here (not in the handler) so any
|
||||
// future caller — CLI, admin UI, bulk importer — inherits the same 10/day
|
||||
// cap without re-implementing it.
|
||||
//
|
||||
// The limiter is in-memory on purpose: invitations are rare, process
|
||||
// restarts are rare, and the consequence of a small bypass during a restart
|
||||
// (a user might get an 11th invite slot) is negligible. A distributed
|
||||
// limiter would be overkill here.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// InviteRateLimit caps how many invitations a single user may send within
|
||||
// InviteRateWindow. Task brief defines 10/day.
|
||||
const (
|
||||
InviteRateLimit = 10
|
||||
InviteRateWindow = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Sentinel errors. Handlers map these to HTTP status codes.
|
||||
var (
|
||||
ErrInviteRateLimited = errors.New("invitation rate limit exceeded")
|
||||
ErrInviteInvalidEmail = errors.New("invalid recipient email")
|
||||
ErrInviteDomainBlocked = errors.New("recipient domain not allowed")
|
||||
)
|
||||
|
||||
// InviteService wires the invitation flow. Allowed domains are checked via
|
||||
// the supplied function so the handler-level whitelist stays the single
|
||||
// source of truth (we don't want two separate lists drifting apart).
|
||||
type InviteService struct {
|
||||
db *sqlx.DB
|
||||
mail *MailService
|
||||
allowedDomains func() []string
|
||||
baseURL string
|
||||
|
||||
mu sync.Mutex
|
||||
sentBy map[uuid.UUID][]time.Time
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewInviteService wires the service. allowedDomains is the same function
|
||||
// used by the auth handler so the list stays consistent. baseURL is
|
||||
// prepended to register links in emails.
|
||||
func NewInviteService(db *sqlx.DB, mail *MailService, allowedDomains func() []string, baseURL string) *InviteService {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://paliad.de"
|
||||
}
|
||||
if allowedDomains == nil {
|
||||
allowedDomains = func() []string { return nil }
|
||||
}
|
||||
return &InviteService{
|
||||
db: db,
|
||||
mail: mail,
|
||||
allowedDomains: allowedDomains,
|
||||
baseURL: baseURL,
|
||||
sentBy: map[uuid.UUID][]time.Time{},
|
||||
clock: func() time.Time { return time.Now() },
|
||||
}
|
||||
}
|
||||
|
||||
// InviteInput is the payload a caller passes to Send.
|
||||
type InviteInput struct {
|
||||
ToEmail string
|
||||
Message string
|
||||
}
|
||||
|
||||
// Send validates, enforces the rate limit, dispatches the email, and writes
|
||||
// the audit row. Returns the persisted invitation ID on success.
|
||||
func (s *InviteService) Send(ctx context.Context, fromUserID uuid.UUID, inviter Inviter, in InviteInput) (uuid.UUID, error) {
|
||||
to := strings.TrimSpace(in.ToEmail)
|
||||
if _, err := mail.ParseAddress(to); err != nil {
|
||||
return uuid.Nil, ErrInviteInvalidEmail
|
||||
}
|
||||
if !s.domainAllowed(to) {
|
||||
return uuid.Nil, ErrInviteDomainBlocked
|
||||
}
|
||||
|
||||
if !s.allowInvite(fromUserID) {
|
||||
return uuid.Nil, ErrInviteRateLimited
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(in.Message)
|
||||
|
||||
lang := "de"
|
||||
if inviter.Lang != nil && *inviter.Lang == "en" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
subject := inviteSubject(lang, inviter.DisplayName)
|
||||
|
||||
if err := s.mail.SendTemplate(TemplateData{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Lang: lang,
|
||||
Name: "invitation",
|
||||
Data: map[string]any{
|
||||
"InviterName": inviter.DisplayName,
|
||||
"InviterEmail": inviter.Email,
|
||||
"ToEmail": to,
|
||||
"Message": msg,
|
||||
"RegisterURL": s.baseURL + "/login",
|
||||
},
|
||||
}); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("send invitation: %w", err)
|
||||
}
|
||||
|
||||
// Audit row written after the send so an SMTP failure doesn't leave a
|
||||
// phantom "sent" record. Rate-limit slots are burned regardless — a
|
||||
// user who triggers repeated SMTP errors still counts against the cap,
|
||||
// keeping worst-case resource use bounded.
|
||||
id, dbErr := s.insertRow(ctx, fromUserID, to, msg)
|
||||
if dbErr != nil {
|
||||
return uuid.Nil, fmt.Errorf("log invitation: %w", dbErr)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Inviter carries the sender's display-facing fields so the service doesn't
|
||||
// need to look them up. The handler fetches the paliad.users row and passes
|
||||
// it through.
|
||||
type Inviter struct {
|
||||
DisplayName string
|
||||
Email string
|
||||
Lang *string
|
||||
}
|
||||
|
||||
func (s *InviteService) domainAllowed(email string) bool {
|
||||
domains := s.allowedDomains()
|
||||
if len(domains) == 0 {
|
||||
// If the configured whitelist is empty, we decline. Fail-closed is
|
||||
// safer than accidentally exposing invitations to random domains.
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
got := strings.ToLower(parts[1])
|
||||
for _, d := range domains {
|
||||
if strings.ToLower(d) == got {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allowInvite both checks and reserves the rate-limit slot in one atomic op,
|
||||
// so two concurrent calls can't both see "9 used" and both go through.
|
||||
func (s *InviteService) allowInvite(userID uuid.UUID) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.clock()
|
||||
cutoff := now.Add(-InviteRateWindow)
|
||||
|
||||
// Compact: drop stamps older than the window. Sent[] stays short
|
||||
// (at most InviteRateLimit live entries per user).
|
||||
seen := s.sentBy[userID]
|
||||
kept := seen[:0]
|
||||
for _, t := range seen {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= InviteRateLimit {
|
||||
s.sentBy[userID] = kept
|
||||
return false
|
||||
}
|
||||
kept = append(kept, now)
|
||||
s.sentBy[userID] = kept
|
||||
return true
|
||||
}
|
||||
|
||||
// RemainingToday reports the unused portion of the rate limit, for surfacing
|
||||
// a "3 invitations left today" hint on the UI. Read-only; safe to call from
|
||||
// a handler that only wants to inspect the counter.
|
||||
func (s *InviteService) RemainingToday(userID uuid.UUID) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cutoff := s.clock().Add(-InviteRateWindow)
|
||||
live := 0
|
||||
for _, t := range s.sentBy[userID] {
|
||||
if t.After(cutoff) {
|
||||
live++
|
||||
}
|
||||
}
|
||||
rem := InviteRateLimit - live
|
||||
if rem < 0 {
|
||||
return 0
|
||||
}
|
||||
return rem
|
||||
}
|
||||
|
||||
func (s *InviteService) insertRow(ctx context.Context, fromUserID uuid.UUID, toEmail, message string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.db.GetContext(ctx, &id,
|
||||
`INSERT INTO paliad.invitations (from_user_id, to_email, message)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id`,
|
||||
fromUserID, toEmail, message,
|
||||
)
|
||||
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)
|
||||
}
|
||||
108
internal/services/invite_service_test.go
Normal file
108
internal/services/invite_service_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// allowInviteTestService builds a bare InviteService that exercises the
|
||||
// rate-limiter without touching SQL or SMTP. The DB and mail fields stay
|
||||
// nil — only the counter logic is under test here.
|
||||
func allowInviteTestService() *InviteService {
|
||||
return &InviteService{
|
||||
allowedDomains: func() []string { return []string{"hlc.com"} },
|
||||
sentBy: map[uuid.UUID][]time.Time{},
|
||||
clock: func() time.Time { return time.Now() },
|
||||
}
|
||||
}
|
||||
|
||||
// Fills the slot up to the cap, then asserts the next call is refused.
|
||||
func TestInviteRateLimit_WithinWindow(t *testing.T) {
|
||||
s := allowInviteTestService()
|
||||
uid := uuid.New()
|
||||
for i := 0; i < InviteRateLimit; i++ {
|
||||
if !s.allowInvite(uid) {
|
||||
t.Fatalf("slot %d should succeed", i)
|
||||
}
|
||||
}
|
||||
if s.allowInvite(uid) {
|
||||
t.Fatal("slot beyond cap should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
// After the window rolls over, old stamps are dropped and the user is
|
||||
// allowed to send again. This is the main invariant behind the 24h cap.
|
||||
func TestInviteRateLimit_WindowRollover(t *testing.T) {
|
||||
s := allowInviteTestService()
|
||||
uid := uuid.New()
|
||||
|
||||
now := time.Date(2026, 4, 20, 10, 0, 0, 0, time.UTC)
|
||||
s.clock = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < InviteRateLimit; i++ {
|
||||
if !s.allowInvite(uid) {
|
||||
t.Fatalf("slot %d should succeed at t=now", i)
|
||||
}
|
||||
}
|
||||
if s.allowInvite(uid) {
|
||||
t.Fatal("11th slot should be denied at t=now")
|
||||
}
|
||||
|
||||
// Jump well past the window — all stamps should age out.
|
||||
s.clock = func() time.Time { return now.Add(InviteRateWindow + time.Minute) }
|
||||
if !s.allowInvite(uid) {
|
||||
t.Fatal("slot after rollover should succeed")
|
||||
}
|
||||
if got := s.RemainingToday(uid); got != InviteRateLimit-1 {
|
||||
t.Errorf("RemainingToday after rollover = %d, want %d", got, InviteRateLimit-1)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent senders must not be able to double-spend the last slot — the
|
||||
// limiter holds the mutex across the check-and-insert.
|
||||
func TestInviteRateLimit_Concurrent(t *testing.T) {
|
||||
s := allowInviteTestService()
|
||||
uid := uuid.New()
|
||||
|
||||
done := make(chan bool, InviteRateLimit*2)
|
||||
for i := 0; i < InviteRateLimit*2; i++ {
|
||||
go func() { done <- s.allowInvite(uid) }()
|
||||
}
|
||||
granted := 0
|
||||
for i := 0; i < InviteRateLimit*2; i++ {
|
||||
if <-done {
|
||||
granted++
|
||||
}
|
||||
}
|
||||
if granted != InviteRateLimit {
|
||||
t.Errorf("granted=%d, want %d", granted, InviteRateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
// domainAllowed must match case-insensitively and fail-closed when the
|
||||
// whitelist is empty.
|
||||
func TestDomainAllowed(t *testing.T) {
|
||||
s := &InviteService{allowedDomains: func() []string { return []string{"hlc.com", "HLC.de"} }}
|
||||
|
||||
allow := map[string]bool{
|
||||
"alice@hlc.com": true,
|
||||
"alice@HLC.COM": true,
|
||||
"alice@hlc.de": true,
|
||||
"alice@hlc.com.evil.ru": false,
|
||||
"alice@example.org": false,
|
||||
"malformed": false,
|
||||
}
|
||||
for addr, want := range allow {
|
||||
if got := s.domainAllowed(addr); got != want {
|
||||
t.Errorf("domainAllowed(%q) = %v, want %v", addr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty whitelist = deny everything.
|
||||
s.allowedDomains = func() []string { return nil }
|
||||
if s.domainAllowed("alice@hlc.com") {
|
||||
t.Error("empty whitelist should deny")
|
||||
}
|
||||
}
|
||||
336
internal/services/mail_service.go
Normal file
336
internal/services/mail_service.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/templates"
|
||||
)
|
||||
|
||||
// MailConfig holds resolved SMTP settings. Built once at startup.
|
||||
type MailConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
FromName string
|
||||
UseTLS bool
|
||||
}
|
||||
|
||||
// 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.
|
||||
type MailService struct {
|
||||
cfg MailConfig
|
||||
enabled bool
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// 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.
|
||||
func NewMailService() (*MailService, error) {
|
||||
cfg := MailConfig{
|
||||
Host: strings.TrimSpace(os.Getenv("SMTP_HOST")),
|
||||
Port: strings.TrimSpace(os.Getenv("SMTP_PORT")),
|
||||
Username: strings.TrimSpace(os.Getenv("SMTP_USERNAME")),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
From: strings.TrimSpace(os.Getenv("SMTP_FROM")),
|
||||
FromName: strings.TrimSpace(os.Getenv("SMTP_FROM_NAME")),
|
||||
UseTLS: !strings.EqualFold(os.Getenv("SMTP_USE_TLS"), "false"),
|
||||
}
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = "465"
|
||||
}
|
||||
if cfg.From == "" && cfg.Username != "" {
|
||||
cfg.From = cfg.Username
|
||||
}
|
||||
if cfg.FromName == "" {
|
||||
cfg.FromName = "Paliad"
|
||||
}
|
||||
|
||||
enabled := cfg.Host != "" && cfg.Username != "" && cfg.Password != "" && cfg.From != ""
|
||||
if !enabled {
|
||||
slog.Warn("mail: SMTP_* env vars incomplete — email delivery disabled",
|
||||
"host_set", cfg.Host != "",
|
||||
"username_set", cfg.Username != "",
|
||||
"password_set", cfg.Password != "",
|
||||
"from_set", cfg.From != "",
|
||||
)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
return &MailService{cfg: cfg, enabled: enabled, templates: tpls}, nil
|
||||
}
|
||||
|
||||
// Enabled reports whether SMTP is configured. Handlers can surface a clearer
|
||||
// error when invite/reminder features require a live SMTP connection.
|
||||
func (s *MailService) Enabled() bool { return s.enabled }
|
||||
|
||||
// Send delivers one multipart/alternative email (HTML + text). A zero or
|
||||
// nil-value service (Enabled() == false) no-ops and returns nil.
|
||||
//
|
||||
// textBody may be empty; when omitted we auto-derive a plain-text fallback
|
||||
// from the HTML so every message still has both parts (some clients flag
|
||||
// HTML-only mail as spam).
|
||||
func (s *MailService) Send(to, subject, htmlBody, textBody string) error {
|
||||
if !s.enabled {
|
||||
slog.Debug("mail: Send skipped (disabled)", "to", to, "subject", subject)
|
||||
return nil
|
||||
}
|
||||
if to == "" {
|
||||
return errors.New("mail: empty recipient")
|
||||
}
|
||||
if subject == "" {
|
||||
return errors.New("mail: empty subject")
|
||||
}
|
||||
if textBody == "" {
|
||||
textBody = htmlToText(htmlBody)
|
||||
}
|
||||
|
||||
msg := buildMIME(s.cfg.From, s.cfg.FromName, to, subject, htmlBody, textBody)
|
||||
return s.deliver(to, msg)
|
||||
}
|
||||
|
||||
// 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".
|
||||
type TemplateData struct {
|
||||
To string
|
||||
Subject 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.
|
||||
func (s *MailService) SendTemplate(in TemplateData) error {
|
||||
html, err := s.RenderTemplate(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !s.enabled {
|
||||
slog.Debug("mail: SendTemplate skipped (disabled)", "to", in.To, "template", in.Name)
|
||||
return nil
|
||||
}
|
||||
return s.Send(in.To, in.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) {
|
||||
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)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"Lang": lang,
|
||||
"Subject": in.Subject,
|
||||
}
|
||||
maps.Copy(payload, in.Data)
|
||||
|
||||
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 out.String(), nil
|
||||
}
|
||||
|
||||
// --- SMTP transport ---------------------------------------------------------
|
||||
|
||||
func (s *MailService) deliver(to string, msg []byte) error {
|
||||
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
|
||||
tlsCfg := &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}
|
||||
|
||||
var (
|
||||
client *smtp.Client
|
||||
err error
|
||||
)
|
||||
if s.cfg.UseTLS {
|
||||
// Implicit TLS (port 465). Establish the TLS connection first, then
|
||||
// hand it to smtp.NewClient. STARTTLS upgrades on port 587 would go
|
||||
// through smtp.Dial + client.StartTLS — different code path, not
|
||||
// needed for Hostinger's submission endpoint.
|
||||
conn, dialErr := tls.Dial("tcp", addr, tlsCfg)
|
||||
if dialErr != nil {
|
||||
return fmt.Errorf("smtp tls dial: %w", dialErr)
|
||||
}
|
||||
client, err = smtp.NewClient(conn, s.cfg.Host)
|
||||
} else {
|
||||
client, err = smtp.Dial(addr)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp connect: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if err := client.Hello(hostnameForHelo()); err != nil {
|
||||
return fmt.Errorf("smtp helo: %w", err)
|
||||
}
|
||||
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
if err := client.Mail(s.cfg.From); err != nil {
|
||||
return fmt.Errorf("smtp mail from: %w", err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("smtp rcpt to: %w", err)
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp data: %w", err)
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
w.Close()
|
||||
return fmt.Errorf("smtp write: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp close data: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func hostnameForHelo() string {
|
||||
if h, err := os.Hostname(); err == nil && h != "" {
|
||||
return h
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// --- 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.
|
||||
func buildMIME(from, fromName, to, subject, htmlBody, textBody string) []byte {
|
||||
boundary := "paliad-mixed-" + randBoundary()
|
||||
fromHeader := from
|
||||
if fromName != "" {
|
||||
fromHeader = fmt.Sprintf("%s <%s>", mime.QEncoding.Encode("utf-8", fromName), from)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, "From: %s\r\n", fromHeader)
|
||||
fmt.Fprintf(&b, "To: %s\r\n", to)
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
|
||||
fmt.Fprintf(&b, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&b, "MIME-Version: 1.0\r\n")
|
||||
fmt.Fprintf(&b, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
|
||||
|
||||
// Plain text part
|
||||
fmt.Fprintf(&b, "--%s\r\n", boundary)
|
||||
fmt.Fprintf(&b, "Content-Type: text/plain; charset=\"utf-8\"\r\n")
|
||||
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
b.WriteString(textBody)
|
||||
b.WriteString("\r\n")
|
||||
|
||||
// HTML part
|
||||
fmt.Fprintf(&b, "--%s\r\n", boundary)
|
||||
fmt.Fprintf(&b, "Content-Type: text/html; charset=\"utf-8\"\r\n")
|
||||
fmt.Fprintf(&b, "Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
b.WriteString(htmlBody)
|
||||
b.WriteString("\r\n")
|
||||
|
||||
fmt.Fprintf(&b, "--%s--\r\n", boundary)
|
||||
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.
|
||||
func randBoundary() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// --- HTML → plain text ------------------------------------------------------
|
||||
|
||||
var (
|
||||
stripTagRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(script|style)>`)
|
||||
stripHTMLRE = regexp.MustCompile(`<[^>]+>`)
|
||||
wsRE = regexp.MustCompile(`[ \t]+`)
|
||||
nlRE = regexp.MustCompile(`\n{3,}`)
|
||||
)
|
||||
|
||||
// htmlToText produces a readable plain-text version of an HTML body. Good
|
||||
// enough for the fallback part of a multipart/alternative message — users
|
||||
// whose clients render HTML will see the styled version; this is only read
|
||||
// by text-only clients and spam filters.
|
||||
func htmlToText(html string) string {
|
||||
s := stripTagRE.ReplaceAllString(html, "")
|
||||
// Convert common block breaks to newlines before stripping.
|
||||
s = strings.NewReplacer(
|
||||
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
|
||||
"</p>", "\n\n", "</div>", "\n", "</tr>", "\n",
|
||||
"</li>", "\n", "</h1>", "\n\n", "</h2>", "\n\n", "</h3>", "\n\n",
|
||||
).Replace(s)
|
||||
s = stripHTMLRE.ReplaceAllString(s, "")
|
||||
s = strings.NewReplacer(
|
||||
" ", " ", "&", "&", "<", "<", ">", ">", """, "\"",
|
||||
"—", "—", "–", "–", "ä", "ä", "ö", "ö",
|
||||
"ü", "ü", "Ä", "Ä", "Ö", "Ö", "Ü", "Ü", "ß", "ß",
|
||||
"…", "…",
|
||||
).Replace(s)
|
||||
s = wsRE.ReplaceAllString(s, " ")
|
||||
s = nlRE.ReplaceAllString(s, "\n\n")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
153
internal/services/mail_service_test.go
Normal file
153
internal/services/mail_service_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHTMLToText covers the HTML→plain-text fallback. Users with text-only
|
||||
// clients still need to read reminder/invite mails, and some spam filters
|
||||
// downrank multipart/alternative when the text part is empty or identical
|
||||
// to the HTML.
|
||||
func TestHTMLToText(t *testing.T) {
|
||||
in := `<html><head><style>b{color:red}</style></head><body>` +
|
||||
`<h1>Frist überfällig</h1><p>Hallo <b>Welt</b></p>` +
|
||||
`<p>Zweite Zeile — ok.</p><script>alert(1)</script></body></html>`
|
||||
got := htmlToText(in)
|
||||
if !strings.Contains(got, "Frist überfällig") {
|
||||
t.Errorf("expected decoded umlauts in %q", got)
|
||||
}
|
||||
if strings.Contains(got, "alert(1)") {
|
||||
t.Errorf("script content leaked into text body: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "<b>") {
|
||||
t.Errorf("raw tag remained in text body: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "—") {
|
||||
t.Errorf("expected em-dash decoded, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTemplateDeadlineReminder verifies that the template bundle wires
|
||||
// base.html + the content template together and fills in user-facing fields.
|
||||
// A typo in deadline_reminder.html would fail here before any SMTP I/O.
|
||||
func TestRenderTemplateDeadlineReminder(t *testing.T) {
|
||||
svc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewMailService: %v", err)
|
||||
}
|
||||
html, err := svc.RenderTemplate(TemplateData{
|
||||
Subject: "[Paliad] Frist morgen: X",
|
||||
Lang: "de",
|
||||
Name: "deadline_reminder",
|
||||
Data: map[string]any{
|
||||
"Kind": "tomorrow",
|
||||
"Title": "Schriftsatz einreichen",
|
||||
"DueDate": "2026-04-21",
|
||||
"AkteAktenzeichen": "2026/0042",
|
||||
"AkteTitle": "Mustermann ./. Musterfrau",
|
||||
"FristURL": "https://paliad.de/fristen/123",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderTemplate: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Paliad", "Schriftsatz einreichen", "2026-04-21", "2026/0042",
|
||||
"Mustermann ./. Musterfrau", "https://paliad.de/fristen/123",
|
||||
"morgen", "#c6f41c",
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("rendered html missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTemplateInvitation covers the invitation template so a typo in
|
||||
// invitation.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",
|
||||
Data: map[string]any{
|
||||
"InviterName": "Anna Schmidt",
|
||||
"InviterEmail": "anna@hlc.com",
|
||||
"ToEmail": "colleague@hlc.com",
|
||||
"Message": "Have a look at Paliad.",
|
||||
"RegisterURL": "https://paliad.de/login",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderTemplate: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Anna Schmidt", "invites you", "Have a look at Paliad.",
|
||||
"https://paliad.de/login", "colleague@hlc.com",
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("rendered html missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderTemplateDeadlineWeekly confirms the weekly summary iterates over
|
||||
// its .Items slice and applies the overdue flag.
|
||||
func TestRenderTemplateDeadlineWeekly(t *testing.T) {
|
||||
svc, err := NewMailService()
|
||||
if err != nil {
|
||||
t.Fatalf("NewMailService: %v", err)
|
||||
}
|
||||
html, err := svc.RenderTemplate(TemplateData{
|
||||
Subject: "[Paliad] Wochenübersicht",
|
||||
Lang: "de",
|
||||
Name: "deadline_weekly",
|
||||
Data: map[string]any{
|
||||
"Count": 2,
|
||||
"FristenURL": "https://paliad.de/fristen",
|
||||
"Items": []map[string]any{
|
||||
{"DueDate": "2026-04-20", "Title": "Heute f.", "AkteAktenzeichen": "2026/0001", "URL": "https://paliad.de/fristen/a", "Overdue": true},
|
||||
{"DueDate": "2026-04-24", "Title": "Später f.", "AkteAktenzeichen": "2026/0002", "URL": "https://paliad.de/fristen/b", "Overdue": false},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RenderTemplate: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Heute f.", "Später f.", "2026/0001", "2026/0002",
|
||||
"https://paliad.de/fristen/a", "https://paliad.de/fristen/b",
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("rendered html missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMIMEHasBothParts ensures the multipart/alternative structure
|
||||
// carries both the text and HTML parts — an earlier refactor dropped one
|
||||
// part by mistake, caught by this.
|
||||
func TestBuildMIMEHasBothParts(t *testing.T) {
|
||||
msg := buildMIME("mail@paliad.de", "Paliad", "to@example.com",
|
||||
"Test", "<p>HTML</p>", "TEXT")
|
||||
body := string(msg)
|
||||
if !strings.Contains(body, "Content-Type: text/plain") {
|
||||
t.Error("missing text/plain part")
|
||||
}
|
||||
if !strings.Contains(body, "Content-Type: text/html") {
|
||||
t.Error("missing text/html part")
|
||||
}
|
||||
if !strings.Contains(body, "multipart/alternative") {
|
||||
t.Error("not multipart/alternative")
|
||||
}
|
||||
if !strings.Contains(body, "TEXT") {
|
||||
t.Error("text body missing")
|
||||
}
|
||||
if !strings.Contains(body, "<p>HTML</p>") {
|
||||
t.Error("html body missing")
|
||||
}
|
||||
}
|
||||
393
internal/services/reminder_service.go
Normal file
393
internal/services/reminder_service.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Package services — ReminderService — hourly deadline-reminder emails.
|
||||
//
|
||||
// Runs one goroutine for the process lifetime. Every hour it scans
|
||||
// paliad.fristen for Fristen that need a reminder, then issues the mail
|
||||
// through MailService and records the delivery in paliad.reminder_log so the
|
||||
// next tick doesn't double-send.
|
||||
//
|
||||
// Three reminder kinds:
|
||||
// * overdue — due_date = today, status = pending.
|
||||
// Heavier alert tone (red header).
|
||||
// * tomorrow — due_date = today+1, status = pending.
|
||||
// Pre-deadline nudge.
|
||||
// * weekly — Monday only: due_date BETWEEN today AND today+7.
|
||||
// Summary table of the week's Fristen. One email per user
|
||||
// aggregating every Frist they created.
|
||||
//
|
||||
// Dedup window: 24h. The service refuses to resend the same (user,
|
||||
// reminder_type, frist_id) pair if a row was inserted in the last 24 hours.
|
||||
// This means at most one overdue / tomorrow email per Frist per day, and
|
||||
// at most one weekly email per user per Monday.
|
||||
//
|
||||
// Recipient selection: the Frist.CreatedBy user — that is, whoever set up
|
||||
// the deadline. Collaborators on the Akte are not notified (avoids spam when
|
||||
// five people share an Akte). A future refinement could add an opt-in
|
||||
// preference table; for now, Frist owner only.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// reminderTickInterval controls how often the service checks for due Fristen.
|
||||
// Hourly is enough given the 24h dedup window and the "tomorrow / today"
|
||||
// granularity — we don't need minute-precision.
|
||||
const reminderTickInterval = time.Hour
|
||||
|
||||
// reminderDedupWindow is the minimum gap between identical reminders (same
|
||||
// user, same type, same Frist). 24h matches the "one per day" policy.
|
||||
const reminderDedupWindow = 24 * time.Hour
|
||||
|
||||
// ReminderService wires the hourly reminder job. Construct with NewReminderService,
|
||||
// start with Start(ctx), stop by cancelling the parent context.
|
||||
type ReminderService struct {
|
||||
db *sqlx.DB
|
||||
mail *MailService
|
||||
users *UserService
|
||||
|
||||
// baseURL is the frontend origin used in email links. Defaults to
|
||||
// https://paliad.de; override via PALIAD_BASE_URL for staging/preview.
|
||||
baseURL string
|
||||
|
||||
// clock returns the current time. Exposed for tests so they can pin
|
||||
// "today" without having to freeze time globally.
|
||||
clock func() time.Time
|
||||
}
|
||||
|
||||
// NewReminderService wires the service. The MailService may be disabled
|
||||
// (Enabled() == false) — in that case Start still runs so logs show what
|
||||
// would have gone out, but every Send is a no-op.
|
||||
func NewReminderService(db *sqlx.DB, mail *MailService, users *UserService, baseURL string) *ReminderService {
|
||||
if baseURL == "" {
|
||||
baseURL = "https://paliad.de"
|
||||
}
|
||||
return &ReminderService{
|
||||
db: db,
|
||||
mail: mail,
|
||||
users: users,
|
||||
baseURL: baseURL,
|
||||
clock: func() time.Time { return time.Now() },
|
||||
}
|
||||
}
|
||||
|
||||
// Start spawns the hourly ticker goroutine. Returns immediately; the loop
|
||||
// exits when ctx is cancelled.
|
||||
func (s *ReminderService) Start(ctx context.Context) {
|
||||
go s.loop(ctx)
|
||||
}
|
||||
|
||||
func (s *ReminderService) loop(ctx context.Context) {
|
||||
slog.Info("reminder: starting hourly scanner",
|
||||
"interval", reminderTickInterval,
|
||||
"mail_enabled", s.mail.Enabled())
|
||||
|
||||
// Run once immediately so a fresh deploy catches up without waiting an
|
||||
// hour — paired with the 24h dedup, this is safe.
|
||||
s.RunOnce(ctx)
|
||||
|
||||
t := time.NewTicker(reminderTickInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("reminder: shutdown")
|
||||
return
|
||||
case <-t.C:
|
||||
s.RunOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunOnce performs one scan+send pass. Exposed so tests (and, later, an
|
||||
// admin trigger endpoint) can exercise the path without waiting for the
|
||||
// ticker. Errors on individual Fristen are logged and swallowed so one bad
|
||||
// row doesn't block the rest of the scan.
|
||||
func (s *ReminderService) RunOnce(ctx context.Context) {
|
||||
now := s.clock()
|
||||
today := now.UTC().Truncate(24 * time.Hour)
|
||||
|
||||
if err := s.sendPerFrist(ctx, today, "overdue"); err != nil {
|
||||
slog.Warn("reminder: overdue scan failed", "error", err)
|
||||
}
|
||||
if err := s.sendPerFrist(ctx, today, "tomorrow"); err != nil {
|
||||
slog.Warn("reminder: tomorrow scan failed", "error", err)
|
||||
}
|
||||
// Weekly runs only on Mondays. time.Weekday returns time.Monday = 1.
|
||||
if now.Weekday() == time.Monday {
|
||||
if err := s.sendWeekly(ctx, today); err != nil {
|
||||
slog.Warn("reminder: weekly scan failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fristReminderRow is the projection needed to render a per-Frist email.
|
||||
// We join the parent Akte for its Aktenzeichen / title and the user row for
|
||||
// the preferred language.
|
||||
type fristReminderRow struct {
|
||||
FristID uuid.UUID `db:"frist_id"`
|
||||
FristTitle string `db:"frist_title"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
AkteAktenzeichen string `db:"akte_aktenzeichen"`
|
||||
AkteTitle string `db:"akte_title"`
|
||||
UserID uuid.UUID `db:"user_id"`
|
||||
UserEmail string `db:"user_email"`
|
||||
UserDisplayName string `db:"user_display_name"`
|
||||
UserLang *string `db:"user_lang"`
|
||||
}
|
||||
|
||||
// sendPerFrist covers the two per-Frist reminder kinds. The query filters on
|
||||
// due_date and the dedup table in a single round-trip so concurrent workers
|
||||
// can't both decide to send (though we only run one reminder process).
|
||||
func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kind string) error {
|
||||
var dueDate time.Time
|
||||
switch kind {
|
||||
case "overdue":
|
||||
dueDate = today
|
||||
case "tomorrow":
|
||||
dueDate = today.AddDate(0, 0, 1)
|
||||
default:
|
||||
return fmt.Errorf("unknown kind %q", kind)
|
||||
}
|
||||
|
||||
// Overdue is "<= today" — include older still-pending Fristen. Tomorrow is
|
||||
// an exact match.
|
||||
var cond string
|
||||
if kind == "overdue" {
|
||||
cond = "f.due_date <= $1"
|
||||
} else {
|
||||
cond = "f.due_date = $1"
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT f.id AS frist_id,
|
||||
f.title AS frist_title,
|
||||
f.due_date AS due_date,
|
||||
a.aktenzeichen AS akte_aktenzeichen,
|
||||
a.title AS akte_title,
|
||||
u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.display_name AS user_display_name,
|
||||
u.lang AS user_lang
|
||||
FROM paliad.fristen f
|
||||
JOIN paliad.akten a ON a.id = f.akte_id
|
||||
JOIN paliad.users u ON u.id = f.created_by
|
||||
WHERE f.status = 'pending'
|
||||
AND ` + cond + `
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM paliad.reminder_log r
|
||||
WHERE r.user_id = u.id
|
||||
AND r.reminder_type = $2
|
||||
AND r.frist_id = f.id
|
||||
AND r.sent_at >= $3
|
||||
)`
|
||||
|
||||
rows := []fristReminderRow{}
|
||||
if err := s.db.SelectContext(ctx, &rows, query,
|
||||
dueDate, kind, s.clock().Add(-reminderDedupWindow),
|
||||
); err != nil {
|
||||
return fmt.Errorf("select fristen for %s: %w", kind, err)
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
if err := s.deliverFristReminder(ctx, kind, r); err != nil {
|
||||
slog.Warn("reminder: deliver failed",
|
||||
"kind", kind, "frist_id", r.FristID, "user_id", r.UserID, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string, r fristReminderRow) error {
|
||||
lang := "de"
|
||||
if r.UserLang != nil && *r.UserLang == "en" {
|
||||
lang = "en"
|
||||
}
|
||||
|
||||
subject := buildSubject(kind, lang, r.FristTitle, 0)
|
||||
data := map[string]any{
|
||||
"Kind": kind,
|
||||
"Title": r.FristTitle,
|
||||
"DueDate": r.DueDate.Format("2006-01-02"),
|
||||
"AkteAktenzeichen": r.AkteAktenzeichen,
|
||||
"AkteTitle": r.AkteTitle,
|
||||
"FristURL": fmt.Sprintf("%s/fristen/%s", s.baseURL, r.FristID),
|
||||
}
|
||||
if err := s.mail.SendTemplate(TemplateData{
|
||||
To: r.UserEmail,
|
||||
Subject: subject,
|
||||
Lang: lang,
|
||||
Name: "deadline_reminder",
|
||||
Data: data,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("send: %w", err)
|
||||
}
|
||||
return s.logSend(ctx, r.UserID, &r.FristID, kind)
|
||||
}
|
||||
|
||||
// weeklyRow captures one user's batch of upcoming Fristen plus their
|
||||
// preferred language. We hold rows per-user in memory and emit one email
|
||||
// per user with the aggregated table.
|
||||
type weeklyRow struct {
|
||||
UserID uuid.UUID `db:"user_id"`
|
||||
UserEmail string `db:"user_email"`
|
||||
UserDisplayName string `db:"user_display_name"`
|
||||
UserLang *string `db:"user_lang"`
|
||||
|
||||
FristID uuid.UUID `db:"frist_id"`
|
||||
FristTitle string `db:"frist_title"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
AkteAktenzeichen string `db:"akte_aktenzeichen"`
|
||||
}
|
||||
|
||||
func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error {
|
||||
end := today.AddDate(0, 0, 7)
|
||||
|
||||
query := `
|
||||
SELECT u.id AS user_id,
|
||||
u.email AS user_email,
|
||||
u.display_name AS user_display_name,
|
||||
u.lang AS user_lang,
|
||||
f.id AS frist_id,
|
||||
f.title AS frist_title,
|
||||
f.due_date AS due_date,
|
||||
a.aktenzeichen AS akte_aktenzeichen
|
||||
FROM paliad.fristen f
|
||||
JOIN paliad.akten a ON a.id = f.akte_id
|
||||
JOIN paliad.users u ON u.id = f.created_by
|
||||
WHERE f.status = 'pending'
|
||||
AND f.due_date >= $1
|
||||
AND f.due_date < $2
|
||||
ORDER BY u.id, f.due_date ASC, f.id ASC`
|
||||
|
||||
rows := []weeklyRow{}
|
||||
if err := s.db.SelectContext(ctx, &rows, query, today, end); err != nil {
|
||||
return fmt.Errorf("select weekly rows: %w", err)
|
||||
}
|
||||
|
||||
// Group by user and drop users we already emailed within the dedup window.
|
||||
byUser := map[uuid.UUID][]weeklyRow{}
|
||||
order := []uuid.UUID{}
|
||||
for _, r := range rows {
|
||||
if _, seen := byUser[r.UserID]; !seen {
|
||||
order = append(order, r.UserID)
|
||||
}
|
||||
byUser[r.UserID] = append(byUser[r.UserID], r)
|
||||
}
|
||||
|
||||
for _, uid := range order {
|
||||
alreadySent, err := s.hasWeeklySentSince(ctx, uid, s.clock().Add(-reminderDedupWindow))
|
||||
if err != nil {
|
||||
slog.Warn("reminder: weekly dedup check failed", "user_id", uid, "error", err)
|
||||
continue
|
||||
}
|
||||
if alreadySent {
|
||||
continue
|
||||
}
|
||||
if err := s.deliverWeekly(ctx, today, byUser[uid]); err != nil {
|
||||
slog.Warn("reminder: weekly deliver failed", "user_id", uid, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ReminderService) hasWeeklySentSince(ctx context.Context, userID uuid.UUID, since time.Time) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.GetContext(ctx, &exists,
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.reminder_log
|
||||
WHERE user_id = $1 AND reminder_type = 'weekly' AND sent_at >= $2
|
||||
)`, userID, since)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, rows []weeklyRow) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
first := rows[0]
|
||||
lang := "de"
|
||||
if first.UserLang != nil && *first.UserLang == "en" {
|
||||
lang = "en"
|
||||
}
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, map[string]any{
|
||||
"DueDate": r.DueDate.Format("2006-01-02"),
|
||||
"Title": r.FristTitle,
|
||||
"AkteAktenzeichen": r.AkteAktenzeichen,
|
||||
"URL": fmt.Sprintf("%s/fristen/%s", s.baseURL, r.FristID),
|
||||
"Overdue": r.DueDate.Before(today),
|
||||
})
|
||||
}
|
||||
|
||||
subject := buildSubject("weekly", lang, "", len(rows))
|
||||
if err := s.mail.SendTemplate(TemplateData{
|
||||
To: first.UserEmail,
|
||||
Subject: subject,
|
||||
Lang: lang,
|
||||
Name: "deadline_weekly",
|
||||
Data: map[string]any{
|
||||
"Count": len(rows),
|
||||
"Items": items,
|
||||
"FristenURL": fmt.Sprintf("%s/fristen", s.baseURL),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("send weekly: %w", err)
|
||||
}
|
||||
return s.logSend(ctx, first.UserID, nil, "weekly")
|
||||
}
|
||||
|
||||
func (s *ReminderService) logSend(ctx context.Context, userID uuid.UUID, fristID *uuid.UUID, kind string) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO paliad.reminder_log (user_id, reminder_type, frist_id)
|
||||
VALUES ($1, $2, $3)`,
|
||||
userID, kind, fristID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert reminder_log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSubject shapes the email subject per kind+lang. Kept here (not in the
|
||||
// template) so the caller can log the subject without rendering HTML.
|
||||
func buildSubject(kind, lang, title string, count int) string {
|
||||
if lang == "en" {
|
||||
switch kind {
|
||||
case "overdue":
|
||||
return "[Paliad] Deadline overdue: " + title
|
||||
case "tomorrow":
|
||||
return "[Paliad] Deadline tomorrow: " + title
|
||||
case "weekly":
|
||||
return fmt.Sprintf("[Paliad] Weekly summary: %d deadline%s", count, pluralS(count))
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case "overdue":
|
||||
return "[Paliad] Frist überfällig: " + title
|
||||
case "tomorrow":
|
||||
return "[Paliad] Frist morgen: " + title
|
||||
case "weekly":
|
||||
return fmt.Sprintf("[Paliad] Wochenübersicht: %d Fristen", count)
|
||||
}
|
||||
return "[Paliad] Erinnerung"
|
||||
}
|
||||
|
||||
func pluralS(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func NewUserService(db *sqlx.DB) *UserService {
|
||||
}
|
||||
|
||||
const userColumns = `id, email, display_name, office, practice_group, role, dezernat,
|
||||
created_at, updated_at`
|
||||
lang, created_at, updated_at`
|
||||
|
||||
// GetByID returns the user row, or (nil, nil) if the user hasn't completed
|
||||
// onboarding yet. Real errors bubble up.
|
||||
|
||||
15
internal/templates/email.go
Normal file
15
internal/templates/email.go
Normal file
@@ -0,0 +1,15 @@
|
||||
// Package templates exposes the embedded email HTML templates. The package
|
||||
// exists so that //go:embed can reach the files from a stable location —
|
||||
// embed directives can't use "..", so every consumer (MailService, tests)
|
||||
// reads through EmailFS instead of crossing package boundaries with relative
|
||||
// paths.
|
||||
package templates
|
||||
|
||||
import "embed"
|
||||
|
||||
// EmailFS contains every HTML template under internal/templates/email/.
|
||||
// The MailService uses these as {{define "content"}} overrides layered onto
|
||||
// base.html; adding a new email means dropping the file in and parsing it.
|
||||
//
|
||||
//go:embed email/*.html
|
||||
var EmailFS embed.FS
|
||||
38
internal/templates/email/base.html
Normal file
38
internal/templates/email/base.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{.Lang}}">
|
||||
<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:#c6f41c;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:#c6f41c;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>
|
||||
42
internal/templates/email/deadline_reminder.html
Normal file
42
internal/templates/email/deadline_reminder.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
{{if eq .Lang "en"}}
|
||||
{{if eq .Kind "overdue"}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#b91c1c;">Deadline overdue</h1>
|
||||
<p style="margin:0 0 12px 0;">The following deadline was due <strong>today or earlier</strong> and is still open:</p>
|
||||
{{else}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Deadline tomorrow</h1>
|
||||
<p style="margin:0 0 12px 0;">The following deadline is due <strong>tomorrow</strong>:</p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{if eq .Kind "overdue"}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#b91c1c;">Frist überfällig</h1>
|
||||
<p style="margin:0 0 12px 0;">Die folgende Frist war <strong>heute oder früher</strong> fällig und ist noch offen:</p>
|
||||
{{else}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Frist morgen fällig</h1>
|
||||
<p style="margin:0 0 12px 0;">Die folgende Frist ist <strong>morgen</strong> fällig:</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;border:1px solid #e7e5e4;border-radius:6px;">
|
||||
<tr>
|
||||
<td style="padding:16px;">
|
||||
<div style="font-weight:600;font-size:16px;margin-bottom:6px;">{{.Title}}</div>
|
||||
<div style="color:#57534e;font-size:13px;margin-bottom:10px;">
|
||||
{{if eq .Lang "en"}}Due:{{else}}Fällig am:{{end}}
|
||||
<strong style="color:#1c1917;">{{.DueDate}}</strong>
|
||||
</div>
|
||||
<div style="color:#57534e;font-size:13px;">
|
||||
{{if eq .Lang "en"}}Matter:{{else}}Akte:{{end}}
|
||||
<strong style="color:#1c1917;">{{.AkteAktenzeichen}}</strong>
|
||||
{{if .AkteTitle}} — {{.AkteTitle}}{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:20px 0 0 0;">
|
||||
<a href="{{.FristURL}}" 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"}}Open in Paliad{{else}}In Paliad öffnen{{end}}
|
||||
</a>
|
||||
</p>
|
||||
{{end}}
|
||||
38
internal/templates/email/deadline_weekly.html
Normal file
38
internal/templates/email/deadline_weekly.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{{define "content"}}
|
||||
{{if eq .Lang "en"}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Deadlines this week</h1>
|
||||
<p style="margin:0 0 16px 0;">You have <strong>{{.Count}}</strong> open deadline{{if ne .Count 1}}s{{end}} in the next 7 days:</p>
|
||||
{{else}}
|
||||
<h1 style="margin:0 0 16px 0;font-size:20px;line-height:1.3;color:#1c1917;">Wochenübersicht Fristen</h1>
|
||||
<p style="margin:0 0 16px 0;">Sie haben <strong>{{.Count}}</strong> offene Frist{{if ne .Count 1}}en{{end}} in den nächsten 7 Tagen:</p>
|
||||
{{end}}
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 16px 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>
|
||||
</tr>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #f5f5f4;white-space:nowrap;{{if .Overdue}}color:#b91c1c;font-weight:600;{{end}}">{{.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>
|
||||
</td>
|
||||
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #f5f5f4;">{{.AkteAktenzeichen}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<p style="margin:20px 0 0 0;">
|
||||
<a href="{{.FristenURL}}" 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}}
|
||||
</a>
|
||||
</p>
|
||||
{{end}}
|
||||
29
internal/templates/email/invitation.html
Normal file
29
internal/templates/email/invitation.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{{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 HLC — matter management, deadline calculations, knowledge tools, and more.</p>
|
||||
{{if .Message}}
|
||||
<div style="background:#f5f5f4;border-left:3px solid #c6f41c;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 HLC 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 HLC — Aktenverwaltung, Fristenberechnung, Wissenswerkzeuge und mehr.</p>
|
||||
{{if .Message}}
|
||||
<div style="background:#f5f5f4;border-left:3px solid #c6f41c;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 HLC-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