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).
92 lines
3.7 KiB
SQL
92 lines
3.7 KiB
SQL
-- t-paliad-147: Bulk team email — paliad.email_broadcasts.
|
|
--
|
|
-- Records every bulk-send sent from /team's "E-Mail an Auswahl" flow.
|
|
-- Powers the /admin/broadcasts viewer (global_admin sees all rows;
|
|
-- senders see their own).
|
|
--
|
|
-- recipient_filter snapshots the filter chips the sender had selected
|
|
-- (project_ids, offices, roles) so a future deploy that tweaks the
|
|
-- filter UX can still render past sends. recipient_user_ids snapshots
|
|
-- the resolved user list — the actual addressees, immune to later
|
|
-- team-membership changes.
|
|
--
|
|
-- Sections:
|
|
-- 1. CREATE paliad.email_broadcasts.
|
|
-- 2. Indexes.
|
|
-- 3. RLS.
|
|
|
|
-- ============================================================================
|
|
-- 1. paliad.email_broadcasts
|
|
-- ============================================================================
|
|
|
|
CREATE TABLE paliad.email_broadcasts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Renderable subject (post-template). Stored verbatim for audit.
|
|
subject text NOT NULL,
|
|
|
|
-- Body source as the sender typed it (Markdown). NOT the per-recipient
|
|
-- rendered output — those are reconstructable by re-rendering with the
|
|
-- snapshotted recipient row, but the source is what we audit.
|
|
body text NOT NULL,
|
|
|
|
-- The sender. FK to paliad.users (not auth.users) so deleting an auth
|
|
-- row leaves the audit trail intact via paliad.users.
|
|
sender_id uuid NOT NULL REFERENCES paliad.users(id),
|
|
|
|
-- Optional template the sender started from. NULL when freeform.
|
|
template_key text,
|
|
|
|
-- Snapshot of filter chips selected at send time. Keys: project_ids
|
|
-- (uuid[]), offices (text[]), roles (text[]). jsonb for forward-compat.
|
|
recipient_filter jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
|
|
-- Resolved addressee list — the user_ids that received (or attempted)
|
|
-- the mail. Immune to subsequent team-membership changes.
|
|
recipient_user_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
|
|
|
-- Per-send result counts (sent, failed, total). jsonb so we can grow
|
|
-- the report shape without a migration.
|
|
send_report jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
|
|
sent_at timestamptz NOT NULL DEFAULT now(),
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- ============================================================================
|
|
-- 2. Indexes
|
|
-- ============================================================================
|
|
|
|
CREATE INDEX email_broadcasts_sent_at_idx
|
|
ON paliad.email_broadcasts (sent_at DESC);
|
|
|
|
CREATE INDEX email_broadcasts_sender_idx
|
|
ON paliad.email_broadcasts (sender_id, sent_at DESC);
|
|
|
|
-- ============================================================================
|
|
-- 3. RLS
|
|
-- ============================================================================
|
|
|
|
ALTER TABLE paliad.email_broadcasts ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Senders can read their own rows; global_admin can read everything.
|
|
-- The Go service layer (BroadcastService) is the load-bearing gate; RLS
|
|
-- here is defence-in-depth for any future auth-context query path.
|
|
CREATE POLICY email_broadcasts_select
|
|
ON paliad.email_broadcasts FOR SELECT
|
|
USING (
|
|
sender_id = auth.uid()
|
|
OR EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = auth.uid()
|
|
AND u.global_role = 'global_admin'
|
|
)
|
|
);
|
|
|
|
-- Inserts only by the sender themselves (defence-in-depth — the service
|
|
-- enforces project_lead-OR-global_admin authorship; RLS only enforces the
|
|
-- self-attribution bit).
|
|
CREATE POLICY email_broadcasts_insert
|
|
ON paliad.email_broadcasts FOR INSERT
|
|
WITH CHECK (sender_id = auth.uid());
|