mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-28 10:52:53 +02:00
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:
parent
becba67dad
commit
f17383f9f2
14 changed files with 1410 additions and 28 deletions
140
apps/mana/apps/web/src/lib/modules/broadcast/api.ts
Normal file
140
apps/mana/apps/web/src/lib/modules/broadcast/api.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 10–60 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue