Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends personalised emails to a filter-narrowed subset of the team. Each recipient gets their own envelope (per-recipient privacy, no shared To: list); From stays on the SMTP infrastructure address with Reply-To set to the human sender so replies route correctly without forging DKIM/SPF. Backend - Migration 057: paliad.email_broadcasts (subject, body, sender_id, template_key, recipient_filter jsonb, recipient_user_ids uuid[], send_report jsonb, sent_at). RLS: senders read own rows, global_admin reads all; inserts must self-attribute. No CHECK-constraint extension to partner_unit_events — broadcasts get their own table per the lock. - BroadcastService (internal/services/broadcast_service.go): validates subject/body/recipient cap (100), enforces project_lead-OR-global_admin, persists audit row, dispatches via 5-deep goroutine pool with 15s per-send timeout. Send report (sent/failed counts + per-recipient errors) is captured back into email_broadcasts.send_report. - markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**, *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped first; only whitelisted tags re-emitted. Script tags and javascript: URLs can't slip through. - Placeholder substitution: {{name}}, {{first_name}}, {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass through unchanged. - mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header on top of the existing multipart/alternative envelope. - TeamService.ListMembershipsIndex: visibility-gated user→project_ids index. Powers the /team project multi-select filter without N round trips per project. - Handlers: POST /api/team/broadcast (gateOnboarded; service enforces authority), GET /api/team/memberships, GET /api/admin/broadcasts (list), GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page). /admin/broadcasts is gateOnboarded (not adminGate) so leads can see their own sends; the service applies the per-row visibility filter. Frontend - /team gains a project multi-select chip dropdown (visible projects loaded from /api/projects, intersected against the memberships index) alongside the existing office and role filters. - "E-Mail an Auswahl (N)" button appears only when canBroadcast() is true (global_admin always; non-admin needs lead-ship on selected projects, or at least one project when no filter is set). Server still re-checks per send. - Compose modal (broadcast.ts): subject + body textarea + optional template dropdown (loads existing email templates and strips Go-template directives) + recipient preview (first 5 + expand) + send. Hard-blocks empty subject/body and N=0. Shows per-send report on success. - /admin/broadcasts viewer: read-only list with click-row-to-expand detail (subject, body, recipient list, send_report counts). Tests - broadcast_service_test.go: placeholder substitution table-driven, Markdown safe-render incl. XSS guards (<script>, javascript: URLs), validation cases (empty subject/body, recipient cap, invalid email), signature rendering DE/EN. - broadcast_service_live_test.go: end-to-end Send + List + Get + visibility rules (lead can send on own project, member cannot, admin sees all, member can't read lead's row). Skips when TEST_DATABASE_URL is unset. i18n: 60 new keys × 2 langs (broadcast modal labels, error messages, recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/ load_error).
234 lines
6.2 KiB
Go
234 lines
6.2 KiB
Go
package services
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
func TestSubstitutePlaceholders(t *testing.T) {
|
|
rec := BroadcastRecipient{
|
|
UserID: uuid.New(),
|
|
Email: "anna@hlc.com",
|
|
DisplayName: "Anna Beispiel",
|
|
FirstName: "Anna",
|
|
RoleOnProject: "lead",
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"name", "Hallo {{name}}", "Hallo Anna Beispiel"},
|
|
{"first_name", "Hi {{first_name}}!", "Hi Anna!"},
|
|
{"role_on_project", "Du bist {{role_on_project}}.", "Du bist lead."},
|
|
{"whitespace tolerated", "{{ first_name }}", "Anna"},
|
|
{"unknown token passes through", "Literal {{example}} stays", "Literal {{example}} stays"},
|
|
{"all three together",
|
|
"{{name}} ({{first_name}}, {{role_on_project}})",
|
|
"Anna Beispiel (Anna, lead)"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := substitutePlaceholders(tc.in, rec)
|
|
if got != tc.want {
|
|
t.Errorf("got %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// renderMarkdownSafe must escape raw HTML and only re-emit a small whitelist
|
|
// of tags. Any leakage of a <script> tag would be an XSS vector since the
|
|
// rendered output goes straight into an HTML email body.
|
|
func TestRenderMarkdownSafe(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
wantContains []string
|
|
wantMissing []string
|
|
}{
|
|
{
|
|
name: "bold",
|
|
in: "**hallo**",
|
|
wantContains: []string{"<strong>hallo</strong>"},
|
|
},
|
|
{
|
|
name: "italic underscore",
|
|
in: "_hallo_",
|
|
wantContains: []string{"<em>hallo</em>"},
|
|
},
|
|
{
|
|
name: "link",
|
|
in: "[paliad](https://paliad.de)",
|
|
wantContains: []string{`<a href="https://paliad.de">paliad</a>`},
|
|
},
|
|
{
|
|
name: "bullet list",
|
|
in: "- erstens\n- zweitens",
|
|
wantContains: []string{"<ul>", "<li>erstens</li>", "<li>zweitens</li>", "</ul>"},
|
|
},
|
|
{
|
|
name: "paragraph break",
|
|
in: "Erste Zeile\n\nZweite Zeile",
|
|
wantContains: []string{"<p>Erste Zeile</p>", "<p>Zweite Zeile</p>"},
|
|
},
|
|
{
|
|
name: "single newline → br",
|
|
in: "Zeile A\nZeile B",
|
|
wantContains: []string{"<p>Zeile A<br>", "Zeile B</p>"},
|
|
},
|
|
{
|
|
name: "script tag escaped",
|
|
in: "Hallo <script>alert(1)</script>",
|
|
wantContains: []string{"<script>", "</script>"},
|
|
wantMissing: []string{"<script>", "alert(1)</script>"},
|
|
},
|
|
{
|
|
name: "link injection attempt — javascript: URL is rejected",
|
|
in: "[click](javascript:alert(1))",
|
|
wantMissing: []string{`href="javascript:`},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := renderMarkdownSafe(tc.in)
|
|
for _, want := range tc.wantContains {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q in %q", want, got)
|
|
}
|
|
}
|
|
for _, miss := range tc.wantMissing {
|
|
if strings.Contains(got, miss) {
|
|
t.Errorf("unexpected %q in %q", miss, got)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFirstNameExtraction(t *testing.T) {
|
|
// senderSignature uses DisplayName directly; firstName extraction is
|
|
// frontend-side. Smoke-test only that DisplayName placeholder lands.
|
|
sender := models.User{
|
|
ID: uuid.New(),
|
|
Email: "max@hlc.com",
|
|
DisplayName: "Max Mustermann",
|
|
}
|
|
sig := senderSignature("de", sender)
|
|
if !strings.Contains(sig, "Max Mustermann") {
|
|
t.Errorf("DisplayName not in signature: %q", sig)
|
|
}
|
|
if !strings.Contains(sig, "Gesendet von") {
|
|
t.Errorf("DE prefix missing: %q", sig)
|
|
}
|
|
if !strings.Contains(sig, `mailto:max@hlc.com`) {
|
|
t.Errorf("mailto link missing: %q", sig)
|
|
}
|
|
sigEN := senderSignature("en", sender)
|
|
if !strings.Contains(sigEN, "Sent by") {
|
|
t.Errorf("EN prefix missing: %q", sigEN)
|
|
}
|
|
}
|
|
|
|
// TestBroadcastValidation exercises the cheap guards that fire before any
|
|
// SQL or SMTP I/O. Constructed with a nil DB so the tests don't need a
|
|
// connection string. The Send path bails out at validation before touching
|
|
// db.ExecContext.
|
|
func TestBroadcastValidation(t *testing.T) {
|
|
mailSvc, err := NewMailService()
|
|
if err != nil {
|
|
t.Fatalf("NewMailService: %v", err)
|
|
}
|
|
svc := NewBroadcastService(nil, mailSvc, nil, nil, NewEmailTemplateService(nil))
|
|
|
|
cases := []struct {
|
|
name string
|
|
in BroadcastInput
|
|
want error
|
|
}{
|
|
{
|
|
name: "empty subject",
|
|
in: BroadcastInput{Subject: "", Body: "x", Recipients: oneRec()},
|
|
want: ErrBroadcastEmptySubject,
|
|
},
|
|
{
|
|
name: "empty body",
|
|
in: BroadcastInput{Subject: "Hi", Body: " ", Recipients: oneRec()},
|
|
want: ErrBroadcastEmptyBody,
|
|
},
|
|
{
|
|
name: "no recipients",
|
|
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nil},
|
|
want: ErrBroadcastNoRecipients,
|
|
},
|
|
{
|
|
name: "too many recipients",
|
|
in: BroadcastInput{Subject: "Hi", Body: "x", Recipients: nRecipients(BroadcastRecipientCap + 1)},
|
|
want: ErrBroadcastTooManyRecipients,
|
|
},
|
|
{
|
|
name: "invalid email",
|
|
in: BroadcastInput{
|
|
Subject: "Hi",
|
|
Body: "x",
|
|
Recipients: []BroadcastRecipient{{
|
|
UserID: uuid.New(),
|
|
Email: "not-an-email",
|
|
}},
|
|
},
|
|
want: ErrBroadcastInvalidEmail,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := svc.Send(t.Context(), uuid.New(), tc.in)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
// Use errors.Is so wrapped errors still match.
|
|
if !errorIs(err, tc.want) {
|
|
t.Errorf("got %v, want %v", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// errorIs is a tiny shim so the test file doesn't need to import "errors".
|
|
// (Imports are kept terse on purpose — see existing test files.)
|
|
func errorIs(have, want error) bool {
|
|
if have == want {
|
|
return true
|
|
}
|
|
if have == nil || want == nil {
|
|
return false
|
|
}
|
|
// Fall back to message-level matching for fmt.Errorf %w wraps.
|
|
return strings.Contains(have.Error(), want.Error())
|
|
}
|
|
|
|
func oneRec() []BroadcastRecipient {
|
|
return []BroadcastRecipient{{
|
|
UserID: uuid.New(),
|
|
Email: "anna@hlc.com",
|
|
DisplayName: "Anna",
|
|
FirstName: "Anna",
|
|
}}
|
|
}
|
|
|
|
func nRecipients(n int) []BroadcastRecipient {
|
|
out := make([]BroadcastRecipient, 0, n)
|
|
for i := 0; i < n; i++ {
|
|
out = append(out, BroadcastRecipient{
|
|
UserID: uuid.New(),
|
|
Email: "user@hlc.com",
|
|
DisplayName: "User",
|
|
FirstName: "User",
|
|
})
|
|
}
|
|
return out
|
|
}
|