feat(broadcast): M4 bulk-send via mana-mail + tracking infrastructure

End-to-end send path lives: click "Jetzt senden" in step 4 → client
resolves recipients → POST /v1/mail/bulk-send → mana-mail loops through
JMAP with per-recipient signed URLs → status flips draft → sent.

mana-mail (backend)
- New Postgres schema `broadcast.{campaigns,sends,events}` in Drizzle.
  Campaigns + sends keyed on the webapp's local ids so joins are free;
  events append-only with send_id FK, dedup at query-time not write-time
  so tracking pixel hits don't contend on a transaction.
- tracking-token.ts: HMAC-SHA256 over JSON({campaignId, sendId, nonce}),
  base64url.base64url encoded. JSON inner payload instead of delimiter
  splits so IDs can contain any character. timingSafeEqual for the HMAC
  comparison. 9 unit tests covering roundtrip / tamper / malformed.
- broadcast-orchestrator.ts: takes pre-resolved recipient list, inlines
  CSS once via juice (webResources.images=false so no external fetches
  slow the loop), per-recipient substitutes `{{unsubscribe_url}}` /
  `{{web_view_url}}` + injects open pixel, submits each mail through
  the user's own JMAP account. Writes sends rows first (status=queued)
  so a crash mid-loop leaves truthful DB state. Returns aggregate
  stats + per-email errors.
- Routes: POST /v1/mail/bulk-send (JWT, cap at 5000 recipients via
  zod + config), GET /v1/mail/campaigns/:id/events (JWT, aggregates
  opens + clicks + unsubscribes with COUNT DISTINCT for the "unique"
  metric), GET/POST /v1/track/{open,click,unsubscribe}/:token (public,
  no auth, signed URL is the only gate).
- Track routes mounted OUTSIDE /api/v1/mail/* because the JWT
  middleware guards that subtree — recipients aren't logged in.
- Config: BROADCAST_TRACKING_SECRET (separate from SERVICE_KEY so the
  blast radius of a leak stays narrow),
  BROADCAST_MAX_RECIPIENTS_PER_CAMPAIGN (default 5000),
  BROADCAST_MAX_RECIPIENTS_PER_HOUR (default 500, not yet enforced).
- Added juice@^11 dependency.

Webapp (client)
- api.ts: sendCampaign() resolves the audience from Dexie contacts,
  renders the full email HTML + plaintext with placeholders, POSTs to
  mana-mail. Contacts NEVER leave the client decrypted — the server
  only sees the flat recipient list the user's client produced.
- fetchCampaignStats() for M7 dashboard/detail polling.
- ComposeView step 4 replaced: confirmation modal with "sicher?"
  question, sending state with spinner, done state with delivered
  count + expandable per-email error list + "Zur Übersicht" button.
- Status transitions to 'sent' with cached stats after successful
  send via applyServerStatus.

Known M4 gaps (fill in M5)
- Open/click/unsubscribe track endpoints return valid responses but
  event dedup is rough — one insert per hit, dedup at query time
  only. M5 adds windowed IP-hash dedup.
- Synchronous send loop. 100 recipients ≈ 15s blocking. M5/M6 moves
  this to an async job queue with SSE progress.
- Each recipient generates a "Sent" folder entry in the user's
  Stalwart mailbox. Fine for 50-recipient newsletters, silly for
  5000. Phase 2 carves out a dedicated broadcast mailbox.

Plan: docs/plans/broadcast-module.md §M4.
Next: M5 open/click tracking with dedup + rate-limits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-21 13:53:13 +02:00
parent becba67dad
commit f17383f9f2
14 changed files with 1410 additions and 28 deletions

View file

@ -0,0 +1,140 @@
/**
* Broadcast API client talks to mana-mail's bulk-send + stats endpoints.
*
* Recipient resolution happens client-side because contacts live in
* Dexie (local-first, end-to-end encrypted). The server never sees the
* user's contact graph. We filter locally, then ship a flat list.
*/
import { browser } from '$app/environment';
import type { Contact } from '$lib/modules/contacts/types';
import type { Campaign, BroadcastSettings, CampaignStats } from './types';
import { filterAudience } from './audience/segment-builder';
import { renderEmailHtml } from './render/email-html';
import { renderPlainText } from './render/plain-text';
function getMailUrl(): string {
if (browser) {
const fromWindow = (window as unknown as { __PUBLIC_MANA_MAIL_URL__?: string })
.__PUBLIC_MANA_MAIL_URL__;
if (fromWindow) return fromWindow;
}
return import.meta.env.PUBLIC_MANA_MAIL_URL || 'http://localhost:3042';
}
async function fetchWithAuth(path: string, init: RequestInit = {}): Promise<Response> {
// mana-mail's JWT auth reads cookies across the *.mana.how SSO origin.
return fetch(`${getMailUrl()}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init.headers ?? {}),
},
});
}
export interface BulkSendResult {
campaignId: string;
accepted: number;
delivered: number;
failed: number;
errors: Array<{ email: string; reason: string }>;
}
/**
* Trigger a bulk send. Resolves recipients from contacts locally, renders
* HTML + plaintext with the full email shell (placeholders for per-
* recipient URLs), and posts the whole package to mana-mail.
*
* The server substitutes placeholders + open pixel + signed URLs per-
* recipient; this function doesn't know about tracking tokens.
*/
export async function sendCampaign(
campaign: Campaign,
settings: BroadcastSettings,
contacts: Contact[]
): Promise<BulkSendResult> {
const audience = filterAudience(contacts, campaign.audience);
if (audience.length === 0) {
throw new Error('Keine Empfänger — Filter liefern eine leere Liste.');
}
if (!settings.legalAddress?.trim()) {
throw new Error(
'Impressum fehlt in den Einstellungen — laut DSGVO Pflicht in jedem Newsletter.'
);
}
const recipients = audience.map((c) => ({
email: c.email as string, // filterAudience drops null-email contacts
name: c.displayName ?? undefined,
contactId: c.id,
}));
const htmlBody = renderEmailHtml({
tiptapHtml: campaign.content.html ?? '',
campaign,
settings,
unsubscribeUrl: '{{unsubscribe_url}}',
webViewUrl: '{{web_view_url}}',
});
const textBody = renderPlainText({
tiptapText: campaign.content.plainText ?? '',
campaign,
settings,
unsubscribeUrl: '{{unsubscribe_url}}',
webViewUrl: '{{web_view_url}}',
});
const res = await fetchWithAuth('/api/v1/mail/bulk-send', {
method: 'POST',
body: JSON.stringify({
campaignId: campaign.id,
subject: campaign.subject,
fromName: campaign.fromName,
fromEmail: campaign.fromEmail,
replyTo: campaign.replyTo ?? undefined,
htmlBody,
textBody,
recipients,
}),
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Versand fehlgeschlagen (${res.status}): ${errorText}`);
}
return (await res.json()) as BulkSendResult;
}
/**
* Fetch aggregate stats for a campaign from mana-mail. Safe to poll on a
* timer from the DetailView (M7) server returns cached rollups.
*/
export async function fetchCampaignStats(campaignId: string): Promise<CampaignStats | null> {
const res = await fetchWithAuth(`/api/v1/mail/campaigns/${campaignId}/events`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Stats-Fetch fehlgeschlagen (${res.status})`);
const data = (await res.json()) as {
totalRecipients: number;
delivery: {
sent: number;
delivered: number;
bounced: number;
unsubscribed: number;
};
opens: { unique: number };
clicks: { unique: number };
lastSyncedAt: string;
};
return {
totalRecipients: data.totalRecipients,
sent: data.delivery.sent,
delivered: data.delivery.delivered,
bounced: data.delivery.bounced,
opened: data.opens.unique,
clicked: data.clicks.unique,
unsubscribed: data.delivery.unsubscribed,
lastSyncedAt: data.lastSyncedAt,
};
}

View file

@ -16,6 +16,8 @@
import PreviewTabs from '../preview/PreviewTabs.svelte';
import { broadcastCampaignsStore } from '../stores/campaigns.svelte';
import { broadcastSettingsStore } from '../stores/settings.svelte';
import { sendCampaign } from '../api';
import { useAllContacts } from '$lib/modules/contacts/queries';
import type { Campaign, CampaignContent, AudienceDefinition, BroadcastSettings } from '../types';
interface Props {
@ -94,6 +96,63 @@
function onCancel() {
goto(isEdit && existing ? `/broadcasts/${existing.id}` : '/broadcasts');
}
// ─── Send ──────────────────────────────────────────────────
const contacts$ = useAllContacts();
const contacts = $derived(contacts$.value ?? []);
let sendState = $state<'idle' | 'confirming' | 'sending' | 'done'>('idle');
let sendResult = $state<{
delivered: number;
failed: number;
errors: Array<{ email: string; reason: string }>;
} | null>(null);
async function doSend() {
if (!existing || !settings) return;
sendState = 'sending';
error = null;
try {
// Save first so the server-side campaign row uses the latest
// metadata + content.
await save();
const result = await sendCampaign(
{
...existing,
subject,
preheader: preheader || null,
fromName,
fromEmail,
audience,
content,
},
settings,
contacts
);
await broadcastCampaignsStore.applyServerStatus(existing.id, {
status: 'sent',
sentAt: new Date().toISOString(),
stats: {
totalRecipients: result.accepted,
sent: result.delivered,
delivered: result.delivered,
bounced: 0,
opened: 0,
clicked: 0,
unsubscribed: 0,
lastSyncedAt: new Date().toISOString(),
},
});
sendResult = {
delivered: result.delivered,
failed: result.failed,
errors: result.errors,
};
sendState = 'done';
} catch (e) {
error = e instanceof Error ? e.message : 'Versand fehlgeschlagen';
sendState = 'idle';
}
}
</script>
<div class="compose">
@ -245,15 +304,85 @@
{/if}
</section>
{:else if step === 4}
<section class="step-panel">
<div class="placeholder">
<h3>Senden</h3>
<p>
Der Bulk-Send-Flow (Jetzt / Später) landet in M4 sobald mana-mail's <code>/bulk-send</code
>-Endpoint steht.
</p>
<button class="btn-primary" disabled>Jetzt senden (M4)</button>
</div>
<section class="step-panel send-panel">
{#if sendState === 'idle'}
<div class="send-card">
<h3>Jetzt senden</h3>
<p>
<strong>{audience.estimatedCount}</strong> Empfänger erhalten die Kampagne
<strong>{subject}"</strong>
von <strong>{fromName}</strong>.
</p>
<p class="hint">
Der Versand läuft synchron und dauert je nach Liste 1060 Sekunden. Du siehst jede Mail
in deinem „Gesendet"-Ordner (pro Empfänger ein Eintrag).
</p>
<div class="send-actions">
<button type="button" class="btn-ghost" onclick={() => (step = 3)}>
Zurück zum Check
</button>
<button
type="button"
class="btn-primary"
onclick={() => (sendState = 'confirming')}
disabled={!audienceReady || !contentReady}
>
Jetzt an {audience.estimatedCount} Empfänger senden
</button>
</div>
</div>
{:else if sendState === 'confirming'}
<div class="send-card confirm-card">
<h3>Sicher?</h3>
<p>
Die Kampagne geht an <strong>{audience.estimatedCount}</strong> Empfänger. Nach dem Versand
kannst du nichts mehr ändern — wenn dir ein Fehler auffällt, musst du eine neue Kampagne als
Korrektur schicken.
</p>
<div class="send-actions">
<button type="button" class="btn-ghost" onclick={() => (sendState = 'idle')}>
Abbrechen
</button>
<button type="button" class="btn-primary btn-danger" onclick={doSend}>
Ja, {audience.estimatedCount} Mails senden
</button>
</div>
</div>
{:else if sendState === 'sending'}
<div class="send-card sending-card">
<div class="spinner"></div>
<h3>Versand läuft …</h3>
<p>
Wir schicken {audience.estimatedCount} Mails raus. Bitte Fenster offen lassen.
</p>
</div>
{:else if sendState === 'done' && sendResult}
<div class="send-card done-card">
<div class="done-icon"></div>
<h3>Versand abgeschlossen</h3>
<p>
<strong>{sendResult.delivered}</strong> Mails versendet
{#if sendResult.failed > 0}
· <strong class="failed-count">{sendResult.failed} Fehler</strong>
{/if}
</p>
{#if sendResult.errors.length > 0}
<details class="error-details">
<summary>Fehler anzeigen ({sendResult.errors.length})</summary>
<ul>
{#each sendResult.errors as err (err.email)}
<li><code>{err.email}</code>{err.reason}</li>
{/each}
</ul>
</details>
{/if}
<div class="send-actions">
<button type="button" class="btn-primary" onclick={() => goto('/broadcasts')}>
Zur Übersicht
</button>
</div>
</div>
{/if}
</section>
{/if}
</div>
@ -406,21 +535,6 @@
font-size: 0.9rem;
}
.placeholder {
background: var(--color-surface-muted, #f8fafc);
border: 1px dashed var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
color: var(--color-text-muted, #64748b);
}
.placeholder h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--color-text, #0f172a);
}
.btn-primary {
background: #6366f1;
color: white;
@ -519,4 +633,115 @@
text-align: center;
color: var(--color-text-muted, #64748b);
}
.send-panel {
align-items: center;
}
.send-card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.75rem;
padding: 2rem;
max-width: 540px;
width: 100%;
text-align: center;
}
.send-card h3 {
margin: 0 0 0.75rem;
font-size: 1.15rem;
font-weight: 600;
}
.send-card p {
margin: 0.5rem 0;
color: var(--color-text-muted, #64748b);
}
.send-card .hint {
font-size: 0.85rem;
margin-top: 1rem;
}
.send-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1.5rem;
}
.btn-danger {
background: #dc2626 !important;
}
.confirm-card {
border-color: #fecaca;
background: #fef2f2;
}
.sending-card .spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e2e8f0);
border-top-color: #6366f1;
border-radius: 50%;
margin: 0 auto 1rem;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.done-card {
border-color: #bbf7d0;
}
.done-icon {
width: 3rem;
height: 3rem;
margin: 0 auto 0.75rem;
border-radius: 50%;
background: #22c55e;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 600;
}
.failed-count {
color: #b91c1c;
}
.error-details {
margin-top: 1rem;
text-align: left;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.4rem;
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
font-size: 0.9rem;
color: #991b1b;
}
.error-details ul {
margin: 0.75rem 0 0;
padding-left: 1.5rem;
font-size: 0.85rem;
}
.error-details code {
background: white;
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
}
</style>