Files
paliad/internal/services/mail_service_test.go
m 11217f7bfa 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.
2026-04-20 12:34:38 +02:00

154 lines
4.8 KiB
Go

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