m's Q4 lock-in (2026-05-20): retrofit the richest existing modal —
broadcast.ts (bulk team-email compose) — onto the unified primitive to
demonstrate its generality on a real-world surface.
Changes:
- Body is built imperatively (renderBody + wireBody) and handed to
openModal as the body element. The submit logic reads form state
from that element on primary-handler invocation.
- Drops the per-modal ESC + close + backdrop + overlay-stack handlers
— the primitive owns them.
- Drops the bespoke .modal-broadcast { width / max-height / padding /
label / input / textarea } CSS overrides. The primitive's data-size
handles width; the existing .form-field rules handle inputs; only
the textarea's code-monospace font is kept as a broadcast-specific
override (placeholder syntax needs to read as code).
- Primary action is "Senden (N)" — clicks invoke the existing
onSubmit logic which POSTs to /api/team/broadcast and on success
shows the per-recipient report inline then closes via the
setTimeout(close, 2500) pattern.
The recipient-list toggle + template dropdown + markdown placeholder
hints are unchanged.
i18n + the .broadcast-recipient-* / .broadcast-recip-* / .broadcast-hint
/ .broadcast-error / .broadcast-success content classes are unchanged.
m's request 2026-05-08 20:12: alongside Paliad's per-recipient
"E-Mail an Auswahl" broadcast (which sends individual envelopes from
the server), users want a one-click way to compose a single multi-
recipient email in their own mail client. Common use case: writing
to a specific team where the response thread should stay client-side
and be visible to every recipient (unlike the privacy-preserving
broadcast where each recipient sees only themselves).
Adds a "Im Mail-Client öffnen" / "Open in mail client" link to the
broadcast modal's recipient summary, alongside the existing
"Alle anzeigen" toggle. Clicking it opens a `mailto:` URL with every
selected recipient comma-separated in the To: line per RFC 6068.
`buildMailtoHref` is exported so it can be unit-tested independently
and reused by other selection surfaces (admin team table, project
team tab) without a refactor.
The existing server-driven broadcast path is unchanged — both options
coexist.
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).