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 := `` + `

Deadline überfällig

Hallo Welt

` + `

Zweite Zeile — ok.

` got := htmlToText(in) if !strings.Contains(got, "Deadline ü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, "") { t.Errorf("raw tag remained in text body: %q", got) } if !strings.Contains(got, "—") { t.Errorf("expected em-dash decoded, got %q", got) } } // TestRenderTemplateDeadlineDigest verifies the bundled-digest template // renders all three category sections, applies the DRINGEND wording on the // evening slot, and folds in IsOtherOwner labels when a row's owner isn't // the recipient. A typo in deadline_digest.de.html would fail here before // any SMTP I/O. // // Also asserts that the rendered subject picks up the evening DRINGEND // framing — the SLO-critical phrasing must survive the template-render // path, not just the body. func TestRenderTemplateDeadlineDigest(t *testing.T) { svc, err := NewMailService() if err != nil { t.Fatalf("NewMailService: %v", err) } subject, html, err := svc.RenderTemplate(TemplateData{ Lang: "de", Name: "deadline_digest", Data: map[string]any{ "Slot": "evening", "IsEvening": true, "OverdueCount": 0, "DueTodayCount": 1, "DueWarningCount": 0, "OpenTotal": 1, "DueToday": []map[string]any{ { "DueDate": "2026-04-28", "Title": "Heute fällig", "ProjectReference": "2026/0002", "ProjectTitle": "Acme v Gadget", "OwnerName": "Self", "IsOtherOwner": false, "URL": "https://paliad.de/deadlines/b", }, }, "DeadlinesURL": "https://paliad.de/deadlines", }, }) if err != nil { t.Fatalf("RenderTemplate: %v", err) } wants := []string{ "Paliad", "DRINGEND", // evening framing on the due_today section "Heute fällig", // row title (passed through as data) "2026/0002", "Acme v Gadget", "https://paliad.de/deadlines/b", // due_today link "https://paliad.de/deadlines", // CTA } for _, want := range wants { if !strings.Contains(html, want) { t.Errorf("rendered html missing %q", want) } } // "Self" owner name should NOT appear because IsOtherOwner=false suppresses // the owner line. if strings.Contains(html, "Self") { t.Errorf("rendered html should not show OwnerName when IsOtherOwner=false: %q", html) } // The DE evening, no-overdue, due_today=1 path should render // "DRINGEND — 1 heute noch offen". wantSubject := "[Paliad] DRINGEND — 1 heute noch offen" if subject != wantSubject { t.Errorf("subject got %q, want %q", subject, wantSubject) } } // TestRenderTemplateDeadlineDigestSystemausfall covers the worst-case // subject: evening slot with overdue rows. Must produce SYSTEMAUSFALL // framing in DE (and SYSTEM FAILURE in EN — covered alongside). func TestRenderTemplateDeadlineDigestSystemausfall(t *testing.T) { svc, _ := NewMailService() for _, tc := range []struct { name string lang string wantSubject string }{ {"de evening overdue", "de", "[Paliad] SYSTEMAUSFALL: 2 überfällig — plus 1 heute offen"}, {"en evening overdue", "en", "[Paliad] SYSTEM FAILURE: 2 overdue — plus 1 still open today"}, } { t.Run(tc.name, func(t *testing.T) { subject, _, err := svc.RenderTemplate(TemplateData{ Lang: tc.lang, Name: "deadline_digest", Data: map[string]any{ "Slot": "evening", "IsEvening": true, "OverdueCount": 2, "DueTodayCount": 1, "DueWarningCount": 0, "OpenTotal": 1, "Overdue": []map[string]any{{}, {}}, "DueToday": []map[string]any{{}}, "DeadlinesURL": "https://paliad.de/deadlines", }, }) if err != nil { t.Fatalf("RenderTemplate: %v", err) } if subject != tc.wantSubject { t.Errorf("subject got %q, want %q", subject, tc.wantSubject) } }) } } // TestRenderTemplateInvitation covers the invitation template so a typo in // invitation.en.html would fail CI. func TestRenderTemplateInvitation(t *testing.T) { svc, err := NewMailService() if err != nil { t.Fatalf("NewMailService: %v", err) } subject, html, err := svc.RenderTemplate(TemplateData{ 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", // Branding placeholder: {{.Firm}} should resolve to the configured // firm name (defaults to "HLC"). Catches accidental deletion of the // template placeholder when nobody set FIRM_NAME in the test env. "platform for HLC", } { if !strings.Contains(html, want) { t.Errorf("rendered html missing %q", want) } } if subject != "[Paliad] Anna Schmidt invites you to Paliad" { t.Errorf("subject got %q", subject) } } // 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", "

HTML

", "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, "

HTML

") { t.Error("html body missing") } }