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

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