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

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

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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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).

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View File

@@ -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

View File

@@ -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"}

View File

@@ -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
View 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,
})
}

View File

@@ -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.

View 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)
}

View 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")
}
}

View 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(
"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", "\"",
"&mdash;", "—", "&ndash;", "", "&auml;", "ä", "&ouml;", "ö",
"&uuml;", "ü", "&Auml;", "Ä", "&Ouml;", "Ö", "&Uuml;", "Ü", "&szlig;", "ß",
"&hellip;", "…",
).Replace(s)
s = wsRE.ReplaceAllString(s, " ")
s = nlRE.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}

View 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 &uuml;berf&auml;llig</h1><p>Hallo <b>Welt</b></p>` +
`<p>Zweite Zeile &mdash; 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")
}
}

View 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"
}

View File

@@ -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.

View 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

View 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 &mdash; <a href="https://paliad.de" style="color:#78716c;text-decoration:none;">paliad.de</a>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -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 &uuml;berf&auml;llig</h1>
<p style="margin:0 0 12px 0;">Die folgende Frist war <strong>heute oder fr&uuml;her</strong> f&auml;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&auml;llig</h1>
<p style="margin:0 0 12px 0;">Die folgende Frist ist <strong>morgen</strong> f&auml;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&auml;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}} &mdash; {{.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 &ouml;ffnen{{end}}
</a>
</p>
{{end}}

View 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&uuml;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&auml;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&auml;llig{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">
{{if eq .Lang "en"}}Title{{else}}Titel{{end}}
</th>
<th align="left" style="padding:10px 12px;font-size:12px;color:#57534e;font-weight:600;border-bottom:1px solid #e7e5e4;">
{{if eq .Lang "en"}}Matter{{else}}Akte{{end}}
</th>
</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}}

View 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 &mdash; 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&auml;dt Sie zu Paliad ein</h1>
<p style="margin:0 0 12px 0;">Paliad ist die Patent-Praxis-Plattform f&uuml;r HLC &mdash; 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&ouml;nnen Sie sie ignorieren.</p>
{{end}}
{{end}}