From d887fc125d450c71d3f7054f410a72fde5e26812 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 21 Apr 2026 14:43:36 +0200 Subject: [PATCH] feat(broadcast): settings + detail view + compliance polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "could actually dogfood" gap: legal address can be set, sent campaigns have a proper view with live stats, and the send path respects DSGVO. Webapp - components/SettingsForm.svelte: sender defaults + Impressum (required, highlighted amber until filled) + footer. Matches the invoices SenderProfileForm pattern — immediate save, dedicated section per concern. - /broadcasts/settings/+page.svelte: mounts the form. ComposeView step 3's "Einstellungen öffnen" CTA now lands somewhere. - views/DetailView.svelte: read-only view for sent/scheduled/cancelled campaigns. 5-card stats grid (sent, open, click, bounce, unsub) with rate percentages. Polls mana-mail every 30s for up to 30 min after mount, persists back to Dexie via applyServerStatus so the list view + widget catch up. Includes a preview of the actual rendered campaign so "what went out" is visible after the fact. - /broadcasts/[id]/+page.svelte: DetailView for non-drafts; drafts bounce to /edit via $effect-triggered goto. - ListView row-click now routes by status (draft → edit, else → detail). mana-mail compliance - Orchestrator loadUnsubscribedEmails(): queries broadcast.sends WHERE status='unsubscribed' scoped to the user, filters the recipient list BEFORE any send rows get written. Campaign's totalRecipients reflects the post-skip count so open rates aren't inflated by "virtual sends". Skipped count surfaces in result.errors for the UI to show. - jmap-client.submitEmail: new extraHeaders param. Sets custom headers via JMAP's `header::asText` property convention. - Orchestrator sets RFC 8058 headers per recipient: List-Unsubscribe: List-Unsubscribe-Post: List-Unsubscribe=One-Click This is what makes Gmail / Apple Mail show their native "Abmelden" button in the message header (not just a body link). All checks clean: 0 TS errors, 37/37 webapp tests, 9/9 tracking-token tests, mana-mail bun build = 2.50 MB. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/modules/broadcast/ListView.svelte | 8 +- .../broadcast/components/SettingsForm.svelte | 239 ++++++++++++ .../modules/broadcast/views/DetailView.svelte | 353 ++++++++++++++++++ .../routes/(app)/broadcasts/[id]/+page.svelte | 44 +++ .../(app)/broadcasts/settings/+page.svelte | 40 ++ .../src/services/broadcast-orchestrator.ts | 58 ++- .../mana-mail/src/services/jmap-client.ts | 14 + 7 files changed, 746 insertions(+), 10 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/broadcast/views/DetailView.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/broadcasts/[id]/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/broadcasts/settings/+page.svelte diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte index de511cc61..d8989260d 100644 --- a/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/broadcast/ListView.svelte @@ -12,12 +12,10 @@ const campaigns = $derived(campaigns$.value ?? []); function openCampaign(id: string, status: string) { - // Drafts go straight to edit; sent/scheduled to a detail view - // (detail lands in M7; until then, edit is the entry for drafts - // and we bounce sent ones back to the list — see canEdit guard - // in the edit route). + // Drafts open in the compose flow for editing; everything else + // opens the read-only DetailView with stats. if (status === 'draft') goto(`/broadcasts/${id}/edit`); - else goto(`/broadcasts/${id}/edit`); + else goto(`/broadcasts/${id}`); } function onNewCampaign() { diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte new file mode 100644 index 000000000..915bfee27 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/components/SettingsForm.svelte @@ -0,0 +1,239 @@ + + + +{#if !settings} +

Lade Einstellungen …

+{:else} +
{ + e.preventDefault(); + save(); + }} + class="form" + > +
+

Absender-Standard

+

+ Wird als Vorbelegung für jede neue Kampagne gesetzt. Du kannst's pro Kampagne überschreiben. +

+ +
+ + +
+ + +
+ +
+

Impressum *

+

+ Pflicht in jedem Newsletter (DSGVO / §5 TMG / Art. 13 DSG). Erscheint im Footer + jeder Kampagne. +

+ + +
+ +
+

Standard-Footer

+

+ Optional — ergänzt den Abmelde-Link + das Impressum am Ende jeder Kampagne. +

+ + +
+ + {#if error} +
{error}
+ {/if} + +
+ + {#if savedAt} + Gespeichert um {savedAt} + {/if} +
+
+{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/views/DetailView.svelte new file mode 100644 index 000000000..64b9d0c47 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/views/DetailView.svelte @@ -0,0 +1,353 @@ + + + +
+
+
+

{campaign.name}

+ + + {STATUS_LABELS[campaign.status].de} + +

{campaign.subject}

+
+
+ {#if campaign.sentAt} +
Versendet {new Date(campaign.sentAt).toLocaleString()}
+ {/if} + {#if campaign.scheduledAt} +
Geplant für {new Date(campaign.scheduledAt).toLocaleString()}
+ {/if} +
+
+ +
+ + {#if campaign.status === 'scheduled'} + + {/if} + Zur Übersicht +
+ + {#if liveStats} +
+
+
Versendet
+
{liveStats.sent}
+
von {liveStats.totalRecipients}
+
+
+
Geöffnet
+
{formatRate(openRate)}
+
{liveStats.opened} Öffnungen
+
+
+
Geklickt
+
{formatRate(clickRate)}
+
{liveStats.clicked} Klicks
+
+
0}> +
Bounced
+
{formatRate(bounceRate)}
+
{liveStats.bounced}
+
+
0}> +
Abgemeldet
+
{formatRate(unsubRate)}
+
{liveStats.unsubscribed}
+
+
+ +

+ {#if polling} + Live-Update … + {:else} + Letzte Aktualisierung: {new Date(liveStats.lastSyncedAt).toLocaleTimeString()} + {/if} +

+ {/if} + + {#if pollError} +
Stats-Fetch fehlgeschlagen: {pollError}
+ {/if} + + {#if settings && previewHtml} +
+

Wie die Kampagne aussah

+ +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/broadcasts/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/broadcasts/[id]/+page.svelte new file mode 100644 index 000000000..be7d52e88 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/broadcasts/[id]/+page.svelte @@ -0,0 +1,44 @@ + + + + {campaign?.name ?? 'Kampagne'} - Mana + + +{#if !campaign && campaigns$.value !== undefined} +
+

Kampagne nicht gefunden.

+ Zurück zur Übersicht +
+{:else if campaign && campaign.status !== 'draft'} + +{:else} +
Lädt …
+{/if} + + diff --git a/apps/mana/apps/web/src/routes/(app)/broadcasts/settings/+page.svelte b/apps/mana/apps/web/src/routes/(app)/broadcasts/settings/+page.svelte new file mode 100644 index 000000000..2d385300e --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/broadcasts/settings/+page.svelte @@ -0,0 +1,40 @@ + + + + Broadcast-Einstellungen - Mana + + +
+
+

Broadcast-Einstellungen

+

Sender-Defaults, Impressum und Footer für alle neuen Kampagnen.

+
+ + +
+ + diff --git a/services/mana-mail/src/services/broadcast-orchestrator.ts b/services/mana-mail/src/services/broadcast-orchestrator.ts index 68ea36a1e..027de08ec 100644 --- a/services/mana-mail/src/services/broadcast-orchestrator.ts +++ b/services/mana-mail/src/services/broadcast-orchestrator.ts @@ -14,6 +14,7 @@ * live in Dexie (local-first) and the server never sees them decrypted. */ +import { eq, and } from 'drizzle-orm'; import juice from 'juice'; import type { Database } from '../db/connection'; import { campaigns, sends, type NewBroadcastSend } from '../db/schema'; @@ -130,6 +131,22 @@ export class BroadcastOrchestrator { return { html, text: '', unsubscribeUrl, webViewUrl }; } + /** + * Fetch the set of email addresses this user's audience has + * unsubscribed from previous campaigns. Lowercased so a later case- + * insensitive compare works. Scope is per-user, not per-campaign — + * once someone unsubscribes from you, they're out of every future + * campaign you send, not just the one they unsubscribed via. + */ + private async loadUnsubscribedEmails(userId: string): Promise> { + const rows = await this.db + .selectDistinct({ email: sends.recipientEmail }) + .from(sends) + .innerJoin(campaigns, eq(sends.campaignId, campaigns.id)) + .where(and(eq(campaigns.userId, userId), eq(sends.status, 'unsubscribed'))); + return new Set(rows.map((r) => r.email.toLowerCase())); + } + /** * Run the bulk send. Returns aggregate stats. Blocks for the duration * of the send (MVP — see module header). @@ -149,10 +166,24 @@ export class BroadcastOrchestrator { throw new Error('No mail account configured for this user'); } + // Compliance gate: drop anyone who's ever unsubscribed from this + // user. Done BEFORE any send rows are written so the dashboard + // counts reflect "tatsächlich versandt" rather than "geplant". + const unsubscribed = await this.loadUnsubscribedEmails(input.userId); + const originalCount = input.recipients.length; + const recipients = input.recipients.filter((r) => !unsubscribed.has(r.email.toLowerCase())); + const skipped = originalCount - recipients.length; + + if (recipients.length === 0) { + throw new Error('Alle Empfänger haben sich abgemeldet — nichts zu senden.'); + } + const now = new Date(); // 1. Persist campaign row (mirror of the webapp's campaign for - // server-side tracking joins). + // server-side tracking joins). totalRecipients reflects the + // post-skip count so open/click rates aren't artificially + // lowered by "virtual sends" that never happened. await this.db .insert(campaigns) .values({ @@ -162,23 +193,31 @@ export class BroadcastOrchestrator { fromEmail: input.fromEmail, fromName: input.fromName, sentAt: now, - totalRecipients: input.recipients.length, + totalRecipients: recipients.length, }) .onConflictDoNothing(); const inlinedHtml = this.inlineOnce(input.htmlBody); const result: BulkSendResult = { campaignId: input.campaignId, - accepted: input.recipients.length, + accepted: recipients.length, delivered: 0, failed: 0, - errors: [], + errors: + skipped > 0 + ? [ + { + email: '(skipped)', + reason: `${skipped} Empfänger übersprungen — haben sich vorher abgemeldet`, + }, + ] + : [], }; // 2. Loop. One send row per recipient, written first (status=queued) // so a crash mid-loop leaves the DB truthful about who got a // mail attempt. - for (const recipient of input.recipients) { + for (const recipient of recipients) { const sendId = crypto.randomUUID(); const nonce = generateNonce(); const sendRow: NewBroadcastSend = { @@ -205,12 +244,21 @@ export class BroadcastOrchestrator { .replaceAll('[Abmelde-Link wird beim Versand eingefügt]', textUnsubUrl); try { + // RFC 8058: `List-Unsubscribe: ` + the POST + // header together tell Gmail / Apple Mail to show the + // native "Abmelden"-button in the header. Without this + // pair, they fall back to just the body link. + const listUnsubUrl = `${this.baseUrl}/api/v1/track/unsubscribe/${textToken}`; await this.jmap.submitEmail(account.stalwartAccountId, { from: { name: input.fromName, email: input.fromEmail }, to: [{ name: recipient.name ?? null, email: recipient.email }], subject: input.subject, textBody: text, htmlBody: html, + extraHeaders: { + 'List-Unsubscribe': `<${listUnsubUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }, }); await this.db .update(sends) diff --git a/services/mana-mail/src/services/jmap-client.ts b/services/mana-mail/src/services/jmap-client.ts index 9018bb4a2..0aa5c0630 100644 --- a/services/mana-mail/src/services/jmap-client.ts +++ b/services/mana-mail/src/services/jmap-client.ts @@ -252,6 +252,13 @@ export class JmapClient { htmlBody?: string; inReplyTo?: string; references?: string[]; + /** + * Extra headers to add to the outgoing mail. Keys should be + * the header name (e.g. "List-Unsubscribe"); values are the + * string header value. Used for RFC 8058 one-click unsubscribe. + * JMAP's property convention is `header::asText`. + */ + extraHeaders?: Record; } ): Promise { const emailId = `draft-${Date.now()}`; @@ -286,6 +293,13 @@ export class JmapClient { if (email.bcc) emailCreate.bcc = email.bcc; if (email.inReplyTo) emailCreate.inReplyTo = email.inReplyTo; if (email.references) emailCreate.references = email.references; + // Custom headers via JMAP's `header::asText` convention. + // Used for RFC 8058 (List-Unsubscribe + List-Unsubscribe-Post). + if (email.extraHeaders) { + for (const [name, value] of Object.entries(email.extraHeaders)) { + emailCreate[`header:${name}:asText`] = value; + } + } const responses = await this.call( [