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 PreviewTabs from '../preview/PreviewTabs.svelte';
import { broadcastCampaignsStore } from '../stores/campaigns.svelte'; import { broadcastCampaignsStore } from '../stores/campaigns.svelte';
import { broadcastSettingsStore } from '../stores/settings.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'; import type { Campaign, CampaignContent, AudienceDefinition, BroadcastSettings } from '../types';
interface Props { interface Props {
@ -94,6 +96,63 @@
function onCancel() { function onCancel() {
goto(isEdit && existing ? `/broadcasts/${existing.id}` : '/broadcasts'); 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> </script>
<div class="compose"> <div class="compose">
@ -245,15 +304,85 @@
{/if} {/if}
</section> </section>
{:else if step === 4} {:else if step === 4}
<section class="step-panel"> <section class="step-panel send-panel">
<div class="placeholder"> {#if sendState === 'idle'}
<h3>Senden</h3> <div class="send-card">
<p> <h3>Jetzt senden</h3>
Der Bulk-Send-Flow (Jetzt / Später) landet in M4 sobald mana-mail's <code>/bulk-send</code <p>
>-Endpoint steht. <strong>{audience.estimatedCount}</strong> Empfänger erhalten die Kampagne
</p> <strong>{subject}"</strong>
<button class="btn-primary" disabled>Jetzt senden (M4)</button> von <strong>{fromName}</strong>.
</div> </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> </section>
{/if} {/if}
</div> </div>
@ -406,21 +535,6 @@
font-size: 0.9rem; 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 { .btn-primary {
background: #6366f1; background: #6366f1;
color: white; color: white;
@ -519,4 +633,115 @@
text-align: center; text-align: center;
color: var(--color-text-muted, #64748b); 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> </style>

90
pnpm-lock.yaml generated
View file

@ -2792,6 +2792,9 @@ importers:
jose: jose:
specifier: ^6.1.2 specifier: ^6.1.2
version: 6.2.2 version: 6.2.2
juice:
specifier: ^11.1.1
version: 11.1.1
postgres: postgres:
specifier: ^3.4.5 specifier: ^3.4.5
version: 3.4.9 version: 3.4.9
@ -9178,6 +9181,10 @@ packages:
cheerio-select@2.1.0: cheerio-select@2.1.0:
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
cheerio@1.0.0:
resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==}
engines: {node: '>=18.17'}
cheerio@1.2.0: cheerio@1.2.0:
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
engines: {node: '>=20.18.1'} engines: {node: '>=20.18.1'}
@ -10436,6 +10443,10 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-goat@3.0.0:
resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==}
engines: {node: '>=10'}
escape-html@1.0.3: escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
@ -11699,6 +11710,9 @@ packages:
htmlparser2@10.1.0: htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
htmlparser2@9.1.0:
resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==}
http-cache-semantics@4.2.0: http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@ -12411,6 +12425,11 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
juice@11.1.1:
resolution: {integrity: sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w==}
engines: {node: '>=18.17'}
hasBin: true
jwa@2.0.1: jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
@ -12883,6 +12902,9 @@ packages:
resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==} resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
mensch@0.3.4:
resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==}
merge-descriptors@1.0.3: merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
@ -13154,6 +13176,11 @@ packages:
engines: {node: '>=4'} engines: {node: '>=4'}
hasBin: true hasBin: true
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
hasBin: true
mimic-fn@1.2.0: mimic-fn@1.2.0:
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -14942,6 +14969,9 @@ packages:
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
engines: {node: '>=20'} engines: {node: '>=20'}
slick@1.12.2:
resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
slugify@1.6.9: slugify@1.6.9:
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -15909,6 +15939,10 @@ packages:
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
engines: {node: '>=10.12.0'} engines: {node: '>=10.12.0'}
valid-data-url@3.0.1:
resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==}
engines: {node: '>=10'}
validate-npm-package-name@5.0.1: validate-npm-package-name@5.0.1:
resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -16190,6 +16224,10 @@ packages:
web-namespaces@2.0.1: web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-resource-inliner@8.0.0:
resolution: {integrity: sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA==}
engines: {node: '>=10.0.0'}
web-streams-polyfill@3.3.3: web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -23528,7 +23566,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 4.0.0 std-env: 4.0.0
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.3': '@vitest/expect@4.1.3':
dependencies: dependencies:
@ -23590,7 +23628,7 @@ snapshots:
sirv: 3.0.2 sirv: 3.0.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) vitest: 4.1.3(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/coverage-v8@4.1.3)(@vitest/ui@4.1.3)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/utils@4.1.3': '@vitest/utils@4.1.3':
dependencies: dependencies:
@ -24953,6 +24991,20 @@ snapshots:
domhandler: 5.0.3 domhandler: 5.0.3
domutils: 3.2.2 domutils: 3.2.2
cheerio@1.0.0:
dependencies:
cheerio-select: 2.1.0
dom-serializer: 2.0.0
domhandler: 5.0.3
domutils: 3.2.2
encoding-sniffer: 0.2.1
htmlparser2: 9.1.0
parse5: 7.3.0
parse5-htmlparser2-tree-adapter: 7.1.0
parse5-parser-stream: 7.1.2
undici: 6.24.1
whatwg-mimetype: 4.0.0
cheerio@1.2.0: cheerio@1.2.0:
dependencies: dependencies:
cheerio-select: 2.1.0 cheerio-select: 2.1.0
@ -26115,6 +26167,8 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
escape-goat@3.0.0: {}
escape-html@1.0.3: {} escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {} escape-string-regexp@1.0.5: {}
@ -27922,6 +27976,13 @@ snapshots:
domutils: 3.2.2 domutils: 3.2.2
entities: 7.0.1 entities: 7.0.1
htmlparser2@9.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
http-cache-semantics@4.2.0: {} http-cache-semantics@4.2.0: {}
http-errors@2.0.1: http-errors@2.0.1:
@ -28898,6 +28959,15 @@ snapshots:
object.assign: 4.1.7 object.assign: 4.1.7
object.values: 1.2.1 object.values: 1.2.1
juice@11.1.1:
dependencies:
cheerio: 1.0.0
commander: 12.1.0
entities: 7.0.1
mensch: 0.3.4
slick: 1.12.2
web-resource-inliner: 8.0.0
jwa@2.0.1: jwa@2.0.1:
dependencies: dependencies:
buffer-equal-constant-time: 1.0.1 buffer-equal-constant-time: 1.0.1
@ -29429,6 +29499,8 @@ snapshots:
next-tick: 1.1.0 next-tick: 1.1.0
timers-ext: 0.1.8 timers-ext: 0.1.8
mensch@0.3.4: {}
merge-descriptors@1.0.3: {} merge-descriptors@1.0.3: {}
merge-descriptors@2.0.0: {} merge-descriptors@2.0.0: {}
@ -30085,6 +30157,8 @@ snapshots:
mime@1.6.0: {} mime@1.6.0: {}
mime@2.6.0: {}
mimic-fn@1.2.0: {} mimic-fn@1.2.0: {}
mimic-fn@2.1.0: {} mimic-fn@2.1.0: {}
@ -32354,6 +32428,8 @@ snapshots:
ansi-styles: 6.2.3 ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0 is-fullwidth-code-point: 5.1.0
slick@1.12.2: {}
slugify@1.6.9: {} slugify@1.6.9: {}
smob@1.6.1: {} smob@1.6.1: {}
@ -33325,6 +33401,8 @@ snapshots:
'@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0 convert-source-map: 2.0.0
valid-data-url@3.0.1: {}
validate-npm-package-name@5.0.1: {} validate-npm-package-name@5.0.1: {}
validator@13.15.35: {} validator@13.15.35: {}
@ -33710,6 +33788,14 @@ snapshots:
web-namespaces@2.0.1: {} web-namespaces@2.0.1: {}
web-resource-inliner@8.0.0:
dependencies:
ansi-colors: 4.1.3
escape-goat: 3.0.0
htmlparser2: 9.1.0
mime: 2.6.0
valid-data-url: 3.0.1
web-streams-polyfill@3.3.3: {} web-streams-polyfill@3.3.3: {}
web-streams-polyfill@4.0.0-beta.3: {} web-streams-polyfill@4.0.0-beta.3: {}

View file

@ -12,10 +12,11 @@
}, },
"dependencies": { "dependencies": {
"@mana/shared-hono": "workspace:*", "@mana/shared-hono": "workspace:*",
"hono": "^4.7.0",
"drizzle-orm": "^0.38.3", "drizzle-orm": "^0.38.3",
"postgres": "^3.4.5", "hono": "^4.7.0",
"jose": "^6.1.2", "jose": "^6.1.2",
"juice": "^11.1.1",
"postgres": "^3.4.5",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -25,6 +25,14 @@ export interface Config {
cors: { cors: {
origins: string[]; origins: string[];
}; };
broadcast: {
/** HMAC secret for tracking tokens. Different from MANA_SERVICE_KEY
* because tracking tokens appear in public URLs the blast
* radius of a leak is narrower with a dedicated secret. */
trackingSecret: string;
maxRecipientsPerCampaign: number;
maxRecipientsPerHour: number;
};
} }
export function loadConfig(): Config { export function loadConfig(): Config {
@ -60,5 +68,20 @@ export function loadConfig(): Config {
cors: { cors: {
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','), origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
}, },
broadcast: {
trackingSecret: requiredEnv(
'BROADCAST_TRACKING_SECRET',
// Dev fallback — MUST be rotated in prod. The requiredEnv
// signature accepts a fallback but throws if both env +
// fallback are empty; the literal below keeps local dev
// working without forcing users to set the var.
'dev-only-broadcast-secret-change-me'
),
maxRecipientsPerCampaign: parseInt(
process.env.BROADCAST_MAX_RECIPIENTS_PER_CAMPAIGN || '5000',
10
),
maxRecipientsPerHour: parseInt(process.env.BROADCAST_MAX_RECIPIENTS_PER_HOUR || '500', 10),
},
}; };
} }

View file

@ -0,0 +1,122 @@
/**
* Broadcast schema server-side mirror of sent campaigns + tracking events.
*
* Content (subject, body, audience) lives in the webapp's Dexie + sync
* pipeline that's the user-authored source. Here we track only what
* the server produces: per-recipient delivery rows + the open/click/
* unsubscribe events that flow in from public tracking endpoints.
*
* Why server-only?
* - Event volume is high (opens can hit thousands per campaign);
* round-tripping through the sync layer would be pointless.
* - Events are write-once from public endpoints; they don't need
* multi-client reconciliation.
* - The user's webapp reads aggregate stats via a summary API, not
* the raw events table.
*/
import { pgSchema, text, timestamp, jsonb, index, integer, bigserial } from 'drizzle-orm/pg-core';
export const broadcastSchema = pgSchema('broadcast');
// ─── Campaigns ───────────────────────────────────────────
/** Server-side echo of a sent campaign. Populated when bulk-send kicks off.
* Keeps just enough metadata to scope events + render audit views. */
export const campaigns = broadcastSchema.table(
'campaigns',
{
// Campaign id from the webapp (LocalCampaign.id) — we intentionally
// carry it through so Dexie-side + Postgres-side can be joined by
// a stable external key without an extra lookup.
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
subject: text('subject'),
fromEmail: text('from_email'),
fromName: text('from_name'),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
totalRecipients: integer('total_recipients').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index('broadcast_campaigns_user_idx').on(t.userId),
})
);
export type BroadcastCampaign = typeof campaigns.$inferSelect;
export type NewBroadcastCampaign = typeof campaigns.$inferInsert;
// ─── Sends (per-recipient delivery record) ──────────────
/**
* One row per (campaign × recipient). Status advances:
* queued sent delivered | bounced | failed
* any unsubscribed (recipient opted out)
*
* `tracking_token` is a server-generated random nonce stored here; the
* HMAC-signed tokens that appear in URLs are derived from
* {campaignId, id, nonce} via the tracking-token service. Storing the
* nonce (not the signed token) means a leaked DB row alone can't be used
* to forge tracking hits.
*/
export const sends = broadcastSchema.table(
'sends',
{
id: text('id').primaryKey(),
campaignId: text('campaign_id')
.notNull()
.references(() => campaigns.id, { onDelete: 'cascade' }),
recipientEmail: text('recipient_email').notNull(),
recipientName: text('recipient_name'),
/** Stable FK back to the user's contact if the segment pulled from
* contacts; null for ad-hoc lists. Sync key, not authoritative. */
recipientContactId: text('recipient_contact_id'),
trackingNonce: text('tracking_nonce').notNull(),
status: text('status').notNull().default('queued'),
sentAt: timestamp('sent_at', { withTimezone: true }),
bouncedAt: timestamp('bounced_at', { withTimezone: true }),
bounceReason: text('bounce_reason'),
unsubscribedAt: timestamp('unsubscribed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
campaignIdx: index('broadcast_sends_campaign_idx').on(t.campaignId),
statusIdx: index('broadcast_sends_status_idx').on(t.status),
emailIdx: index('broadcast_sends_email_idx').on(t.recipientEmail),
})
);
export type BroadcastSend = typeof sends.$inferSelect;
export type NewBroadcastSend = typeof sends.$inferInsert;
// ─── Events (opens, clicks, unsubscribes) ───────────────
/**
* Append-only event log. Every hit on a tracking endpoint becomes a row.
* Dedup happens at query time (COUNT DISTINCT on send_id + day) because
* trying to dedup at write time creates contention on the hot tracking
* path a duplicate event row is cheaper than a transaction.
*/
export const events = broadcastSchema.table(
'events',
{
id: bigserial('id', { mode: 'number' }).primaryKey(),
sendId: text('send_id')
.notNull()
.references(() => sends.id, { onDelete: 'cascade' }),
kind: text('kind').notNull(), // 'open' | 'click' | 'unsubscribe'
occurredAt: timestamp('occurred_at', { withTimezone: true }).defaultNow().notNull(),
/** HMAC hash — not PII, just for same-recipient dedup inside a window. */
ipHash: text('ip_hash'),
userAgentHash: text('user_agent_hash'),
linkUrl: text('link_url'),
metadata: jsonb('metadata'),
},
(t) => ({
sendKindIdx: index('broadcast_events_send_kind_idx').on(t.sendId, t.kind),
occurredIdx: index('broadcast_events_occurred_idx').on(t.occurredAt),
})
);
export type BroadcastEvent = typeof events.$inferSelect;
export type NewBroadcastEvent = typeof events.$inferInsert;

View file

@ -1 +1,2 @@
export * from './mail'; export * from './mail';
export * from './broadcast';

View file

@ -15,6 +15,7 @@ import { serviceAuth } from './middleware/service-auth';
import { JmapClient } from './services/jmap-client'; import { JmapClient } from './services/jmap-client';
import { AccountService } from './services/account-service'; import { AccountService } from './services/account-service';
import { MailService } from './services/mail-service'; import { MailService } from './services/mail-service';
import { BroadcastOrchestrator } from './services/broadcast-orchestrator';
import { healthRoutes } from './routes/health'; import { healthRoutes } from './routes/health';
import { createThreadRoutes } from './routes/threads'; import { createThreadRoutes } from './routes/threads';
import { createMessageRoutes } from './routes/messages'; import { createMessageRoutes } from './routes/messages';
@ -22,6 +23,9 @@ import { createSendRoutes } from './routes/send';
import { createLabelRoutes } from './routes/labels'; import { createLabelRoutes } from './routes/labels';
import { createAccountRoutes } from './routes/accounts'; import { createAccountRoutes } from './routes/accounts';
import { createInternalRoutes } from './routes/internal'; import { createInternalRoutes } from './routes/internal';
import { createBroadcastSendRoutes } from './routes/broadcast-send';
import { createBroadcastTrackRoutes } from './routes/broadcast-track';
import { createBroadcastStatsRoutes } from './routes/broadcast-stats';
// ─── Bootstrap ────────────────────────────────────────────── // ─── Bootstrap ──────────────────────────────────────────────
@ -32,6 +36,13 @@ const db = getDb(config.databaseUrl);
const jmapClient = new JmapClient(config.stalwart); const jmapClient = new JmapClient(config.stalwart);
const accountService = new AccountService(db, config.stalwart); const accountService = new AccountService(db, config.stalwart);
const mailService = new MailService(db, jmapClient, accountService); const mailService = new MailService(db, jmapClient, accountService);
const broadcastOrchestrator = new BroadcastOrchestrator(
db,
jmapClient,
accountService,
config.broadcast.trackingSecret,
config.baseUrl
);
// ─── App ──────────────────────────────────────────────────── // ─── App ────────────────────────────────────────────────────
@ -50,10 +61,24 @@ app.use(
// Health check (no auth) // Health check (no auth)
app.route('/health', healthRoutes); app.route('/health', healthRoutes);
// Public tracking routes — NO auth. Recipients click these from
// emails without being logged in. Mounted under /api/v1/track/* so
// they sit outside the /api/v1/mail/* JWT middleware. Registered
// BEFORE the JWT middleware to avoid middleware leakage.
app.route(
'/api/v1/track',
createBroadcastTrackRoutes(db, config.broadcast.trackingSecret, config.baseUrl)
);
// User-facing routes (JWT auth) // User-facing routes (JWT auth)
app.use('/api/v1/mail/*', jwtAuth(config.manaAuthUrl)); app.use('/api/v1/mail/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/mail', createThreadRoutes(mailService)); app.route('/api/v1/mail', createThreadRoutes(mailService));
app.route('/api/v1/mail', createSendRoutes(mailService)); app.route('/api/v1/mail', createSendRoutes(mailService));
app.route(
'/api/v1/mail',
createBroadcastSendRoutes(broadcastOrchestrator, config.broadcast.maxRecipientsPerCampaign)
);
app.route('/api/v1/mail', createBroadcastStatsRoutes(db));
app.route('/api/v1/mail', createLabelRoutes(mailService)); app.route('/api/v1/mail', createLabelRoutes(mailService));
app.route('/api/v1/mail', createAccountRoutes(accountService)); app.route('/api/v1/mail', createAccountRoutes(accountService));
app.route('/api/v1/mail/messages', createMessageRoutes(mailService)); app.route('/api/v1/mail/messages', createMessageRoutes(mailService));

View file

@ -0,0 +1,63 @@
/**
* POST /v1/mail/bulk-send JWT auth.
*
* The webapp resolves recipients client-side (contacts live in Dexie) and
* POSTs a flat list here. Hard-capped at config.broadcastMaxRecipients so
* a misbehaving client can't send 100k mails in one request.
*/
import { Hono } from 'hono';
import { z } from 'zod';
import type { BroadcastOrchestrator } from '../services/broadcast-orchestrator';
import type { AuthUser } from '../middleware/jwt-auth';
const recipientSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
contactId: z.string().optional(),
});
const bulkSendSchema = z.object({
campaignId: z.string().min(1),
subject: z.string().min(1),
fromName: z.string().min(1),
fromEmail: z.string().email(),
replyTo: z.string().email().optional(),
htmlBody: z.string().min(1),
textBody: z.string().min(1),
recipients: z.array(recipientSchema).min(1).max(5000),
});
export function createBroadcastSendRoutes(
orchestrator: BroadcastOrchestrator,
maxRecipients: number
) {
return new Hono<{ Variables: { user: AuthUser } }>().post('/bulk-send', async (c) => {
const user = c.get('user');
const body = bulkSendSchema.parse(await c.req.json());
if (body.recipients.length > maxRecipients) {
return c.json(
{
error: `Recipient count ${body.recipients.length} exceeds configured cap ${maxRecipients}`,
},
400
);
}
const result = await orchestrator.run({
userId: user.userId,
campaignId: body.campaignId,
subject: body.subject,
fromName: body.fromName,
fromEmail: body.fromEmail,
replyTo: body.replyTo,
htmlBody: body.htmlBody,
textBody: body.textBody,
recipients: body.recipients,
maxRecipients,
});
return c.json(result);
});
}

View file

@ -0,0 +1,78 @@
/**
* GET /v1/mail/campaigns/:id/events JWT auth.
*
* Aggregate stats for a campaign. Returns counts derived from the
* events table plus delivery status from sends. The webapp's
* BroadcastStats type mirrors this response shape.
*/
import { Hono } from 'hono';
import { eq, sql, and } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { campaigns, sends, events } from '../db/schema';
import type { AuthUser } from '../middleware/jwt-auth';
export function createBroadcastStatsRoutes(db: Database) {
return new Hono<{ Variables: { user: AuthUser } }>().get('/campaigns/:id/events', async (c) => {
const user = c.get('user');
const campaignId = c.req.param('id');
// Ownership check: only the campaign's creator sees its stats.
const campaign = await db
.select()
.from(campaigns)
.where(and(eq(campaigns.id, campaignId), eq(campaigns.userId, user.userId)))
.limit(1);
if (campaign.length === 0) {
return c.json({ error: 'not found' }, 404);
}
// Aggregate delivery status counts.
const deliveryRows = await db
.select({
status: sends.status,
count: sql<number>`count(*)::int`,
})
.from(sends)
.where(eq(sends.campaignId, campaignId))
.groupBy(sends.status);
const delivery = Object.fromEntries(deliveryRows.map((r) => [r.status, r.count])) as Record<
string,
number
>;
// Distinct-recipient event counts. COUNT(DISTINCT send_id) gives
// us the "unique opens / clicks" the user actually cares about;
// raw open counts include re-opens and image-proxy fetches.
const eventRows = await db
.select({
kind: events.kind,
uniqueCount: sql<number>`count(distinct ${events.sendId})::int`,
totalCount: sql<number>`count(*)::int`,
})
.from(events)
.innerJoin(sends, eq(events.sendId, sends.id))
.where(eq(sends.campaignId, campaignId))
.groupBy(events.kind);
const eventCounts = Object.fromEntries(
eventRows.map((r) => [r.kind, { unique: r.uniqueCount, total: r.totalCount }])
) as Record<string, { unique: number; total: number }>;
return c.json({
campaignId,
totalRecipients: campaign[0].totalRecipients,
delivery: {
queued: delivery.queued ?? 0,
sent: delivery.sent ?? 0,
delivered: delivery.delivered ?? 0,
bounced: delivery.bounced ?? 0,
failed: delivery.failed ?? 0,
unsubscribed: delivery.unsubscribed ?? 0,
},
opens: eventCounts.open ?? { unique: 0, total: 0 },
clicks: eventCounts.click ?? { unique: 0, total: 0 },
unsubscribes: eventCounts.unsubscribe ?? { unique: 0, total: 0 },
lastSyncedAt: new Date().toISOString(),
});
});
}

View file

@ -0,0 +1,183 @@
/**
* Public tracking endpoints NO auth (recipients aren't logged in).
*
* Verification happens via HMAC on the token in the URL. A leaked / forged
* token just silently falls through to a graceful response; we never
* reveal whether a token was recognised or not, because that would help
* an attacker probe the space.
*
* M4 status: tokens are signed and validated, but event persistence is
* a minimal stub inserts with metadata only, no dedup / IP-hashing.
* M5 adds the full tracking pipeline (rate-limited dedup, user-agent
* hashing, bounce webhook integration).
*/
import { Hono } from 'hono';
import { createHash } from 'node:crypto';
import { eq } from 'drizzle-orm';
import type { Database } from '../db/connection';
import { sends, events } from '../db/schema';
import { verifyToken } from '../services/tracking-token';
// ─── Response helpers ───────────────────────────────────
/**
* 1×1 transparent GIF for the open-tracking pixel. Generated once this
* is the smallest valid GIF that renders correctly in every mail client.
*/
const PIXEL_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
function pixelResponse(): Response {
return new Response(PIXEL_GIF, {
status: 200,
headers: {
'content-type': 'image/gif',
'content-length': String(PIXEL_GIF.byteLength),
'cache-control': 'no-store, no-cache, must-revalidate, private',
pragma: 'no-cache',
expires: '0',
},
});
}
function hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex').slice(0, 16);
}
function hashUserAgent(ua: string): string {
return createHash('sha256').update(ua).digest('hex').slice(0, 16);
}
// ─── Routes ────────────────────────────────────────────
export function createBroadcastTrackRoutes(db: Database, trackingSecret: string, baseUrl: string) {
const app = new Hono();
/**
* GET /track/open/:token 1×1 pixel. Always returns the pixel even
* on bad tokens so there's no signal to whoever's probing.
*/
app.get('/track/open/:token', async (c) => {
const token = c.req.param('token');
const payload = verifyToken(token, trackingSecret);
if (!payload) return pixelResponse();
const ip = c.req.header('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown';
const ua = c.req.header('user-agent') ?? '';
// Best-effort insert — if the DB is unreachable, we still return
// the pixel so the email displays correctly in the client.
try {
await db.insert(events).values({
sendId: payload.sendId,
kind: 'open',
ipHash: hashIp(ip),
userAgentHash: hashUserAgent(ua),
});
} catch {
// Swallow — see comment above.
}
return pixelResponse();
});
/**
* GET /track/click/:token?url=... 302 to the original URL. Same
* graceful-fall-through on verification failure so a broken token
* doesn't strand the recipient on a dead page.
*/
app.get('/track/click/:token', async (c) => {
const token = c.req.param('token');
const targetUrl = c.req.query('url');
if (!targetUrl) return c.text('missing url', 400);
// Validate target is http(s) to prevent open-redirect-to-javascript:
// et al. If it's not, refuse rather than bounce through.
if (!/^https?:\/\//i.test(targetUrl)) return c.text('bad url', 400);
const payload = verifyToken(token, trackingSecret);
if (payload) {
try {
await db.insert(events).values({
sendId: payload.sendId,
kind: 'click',
linkUrl: targetUrl,
ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown'),
userAgentHash: hashUserAgent(c.req.header('user-agent') ?? ''),
});
} catch {
// Best-effort; continue to redirect.
}
}
return c.redirect(targetUrl, 302);
});
/**
* GET /track/unsubscribe/:token confirmation page + implicit
* one-click unsubscribe.
*
* RFC 8058 wants one-click via POST to this URL. We also handle GET
* so a plain anchor link works for older clients but we still
* persist the unsubscribe on GET because the user actively clicked.
*/
app.get('/track/unsubscribe/:token', async (c) => {
const token = c.req.param('token');
const payload = verifyToken(token, trackingSecret);
if (!payload) {
return c.html(
'<!doctype html><html><body><h1>Ungültiger Abmelde-Link</h1><p>Der Link ist entweder abgelaufen oder wurde manipuliert.</p></body></html>',
400
);
}
try {
await db
.update(sends)
.set({ status: 'unsubscribed', unsubscribedAt: new Date() })
.where(eq(sends.id, payload.sendId));
await db.insert(events).values({
sendId: payload.sendId,
kind: 'unsubscribe',
});
} catch {
// Still render the success page — the recipient did their part,
// db hiccups are our problem not theirs.
}
return c.html(
'<!doctype html><html><head><meta charset="utf-8"><title>Abgemeldet</title></head>' +
'<body style="font-family:system-ui,sans-serif;max-width:480px;margin:48px auto;padding:24px;color:#0f172a;">' +
'<h1 style="font-size:24px;">Du wurdest abgemeldet</h1>' +
'<p>Du bekommst von uns keine weiteren Newsletter mehr.</p>' +
'<p style="color:#64748b;font-size:14px;">Falls das ein Versehen war, antworte einfach auf eine unserer letzten E-Mails — wir kümmern uns darum.</p>' +
'</body></html>'
);
});
/**
* POST /track/unsubscribe/:token RFC 8058 one-click unsubscribe.
* Same effect as GET but returns 204 so the client doesn't show a
* page (Gmail/Apple-Mail's native button calls this).
*/
app.post('/track/unsubscribe/:token', async (c) => {
const token = c.req.param('token');
const payload = verifyToken(token, trackingSecret);
if (!payload) return c.text('', 400);
try {
await db
.update(sends)
.set({ status: 'unsubscribed', unsubscribedAt: new Date() })
.where(eq(sends.id, payload.sendId));
await db.insert(events).values({ sendId: payload.sendId, kind: 'unsubscribe' });
} catch {
return c.text('', 500);
}
return c.text('', 204);
});
void baseUrl; // reserved for future asset URLs
return app;
}

View file

@ -0,0 +1,244 @@
/**
* Broadcast orchestrator takes a campaign payload + recipient list,
* produces per-recipient HTML with substituted tracking URLs, submits
* each email via Stalwart (reusing the user's mailbox), and writes
* progress to broadcast.sends / broadcast.campaigns.
*
* MVP note: this is a synchronous loop. For 100 recipients it takes
* ~15s (JMAP submit latency-dominated) and the API call simply blocks
* until done. Phase 2 wraps this in an async job queue with SSE
* progress updates; the loop logic stays the same.
*
* Recipient resolution is NOT done here the webapp ships a pre-
* resolved recipient list in the bulk-send payload because contacts
* live in Dexie (local-first) and the server never sees them decrypted.
*/
import juice from 'juice';
import type { Database } from '../db/connection';
import { campaigns, sends, type NewBroadcastSend } from '../db/schema';
import type { AccountService } from './account-service';
import type { JmapClient } from './jmap-client';
import { generateNonce, signToken } from './tracking-token';
export interface BulkRecipient {
email: string;
name?: string;
/** Stable back-link to the user's contact, if resolvable. Opaque to us. */
contactId?: string;
}
export interface BulkSendInput {
userId: string;
campaignId: string;
subject: string;
fromName: string;
fromEmail: string;
replyTo?: string;
htmlBody: string;
textBody: string;
recipients: BulkRecipient[];
/** Max recipients the campaign allows hard-capped by the route
* against the server's MAX_RECIPIENTS_PER_CAMPAIGN config. */
maxRecipients: number;
}
export interface BulkSendResult {
campaignId: string;
accepted: number;
delivered: number;
failed: number;
/** Fine-grained error per recipient useful for the UI to show a
* "3 bounces" badge without waiting on bounce-webhook propagation. */
errors: Array<{ email: string; reason: string }>;
}
export class BroadcastOrchestrator {
constructor(
private db: Database,
private jmap: JmapClient,
private accountService: AccountService,
private trackingSecret: string,
private baseUrl: string
) {}
/**
* Inline CSS once for the whole campaign so every recipient gets the
* same final HTML structure only the per-recipient URLs change.
*
* juice walks `<style>` blocks + external stylesheets and splatters
* matching rules into inline style="". Our client-side render is
* already inline-heavy so this pass mostly normalises edge cases.
*/
private inlineOnce(html: string): string {
try {
return juice(html, {
preserveMediaQueries: true,
removeStyleTags: false,
webResources: {
// We never fetch external resources (images are already
// absolute URLs to mana-media). Blocking the resolver
// prevents juice from trying to load anything over HTTP
// and slowing the send loop.
images: false,
},
});
} catch {
// If juice chokes, fall back to the input HTML — a mail with
// un-inlined styles is still better than no mail.
return html;
}
}
/**
* Replace `{{unsubscribe_url}}` and `{{web_view_url}}` placeholders
* with signed per-recipient URLs. Client-side renderer puts the
* placeholders in; this is the only place they're resolved.
*/
private substituteUrls(
inlinedHtml: string,
campaignId: string,
sendId: string,
nonce: string
): { html: string; text: string; unsubscribeUrl: string; webViewUrl: string } {
const token = signToken({ campaignId, sendId, nonce }, this.trackingSecret);
// Track endpoints live outside /api/v1/mail/* because the JWT
// middleware guards that whole subtree — recipients aren't logged in.
const unsubscribeUrl = `${this.baseUrl}/api/v1/track/unsubscribe/${token}`;
const webViewUrl = `${this.baseUrl}/api/v1/track/view/${campaignId}`;
const openPixelUrl = `${this.baseUrl}/api/v1/track/open/${token}`;
// Two replace passes — placeholders in both text and html bodies.
const replaceAll = (s: string) =>
s
.replaceAll('{{unsubscribe_url}}', unsubscribeUrl)
.replaceAll('#unsubscribe-preview', unsubscribeUrl)
.replaceAll('{{web_view_url}}', webViewUrl)
.replaceAll('#web-view-preview', webViewUrl);
let html = replaceAll(inlinedHtml);
// Inject the open pixel just before </body>. No-op for malformed
// HTML — we still send.
const pixel = `<img src="${openPixelUrl}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px;">`;
if (html.includes('</body>')) {
html = html.replace('</body>', `${pixel}</body>`);
} else {
html = `${html}\n${pixel}`;
}
return { html, text: '', unsubscribeUrl, webViewUrl };
}
/**
* Run the bulk send. Returns aggregate stats. Blocks for the duration
* of the send (MVP see module header).
*/
async run(input: BulkSendInput): Promise<BulkSendResult> {
if (input.recipients.length === 0) {
throw new Error('No recipients provided');
}
if (input.recipients.length > input.maxRecipients) {
throw new Error(
`Recipient count ${input.recipients.length} exceeds cap ${input.maxRecipients}`
);
}
const account = await this.accountService.getDefaultAccount(input.userId);
if (!account?.stalwartAccountId) {
throw new Error('No mail account configured for this user');
}
const now = new Date();
// 1. Persist campaign row (mirror of the webapp's campaign for
// server-side tracking joins).
await this.db
.insert(campaigns)
.values({
id: input.campaignId,
userId: input.userId,
subject: input.subject,
fromEmail: input.fromEmail,
fromName: input.fromName,
sentAt: now,
totalRecipients: input.recipients.length,
})
.onConflictDoNothing();
const inlinedHtml = this.inlineOnce(input.htmlBody);
const result: BulkSendResult = {
campaignId: input.campaignId,
accepted: input.recipients.length,
delivered: 0,
failed: 0,
errors: [],
};
// 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) {
const sendId = crypto.randomUUID();
const nonce = generateNonce();
const sendRow: NewBroadcastSend = {
id: sendId,
campaignId: input.campaignId,
recipientEmail: recipient.email,
recipientName: recipient.name ?? null,
recipientContactId: recipient.contactId ?? null,
trackingNonce: nonce,
status: 'queued',
};
await this.db.insert(sends).values(sendRow);
const { html } = this.substituteUrls(inlinedHtml, input.campaignId, sendId, nonce);
// Text body: also substitute URL placeholders so plain-text
// clients get working links. Sign once per recipient.
const textToken = signToken(
{ campaignId: input.campaignId, sendId, nonce },
this.trackingSecret
);
const textUnsubUrl = `${this.baseUrl}/api/v1/track/unsubscribe/${textToken}`;
const text = input.textBody
.replaceAll('{{unsubscribe_url}}', textUnsubUrl)
.replaceAll('[Abmelde-Link wird beim Versand eingefügt]', textUnsubUrl);
try {
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,
});
await this.db
.update(sends)
.set({ status: 'sent', sentAt: new Date() })
.where(eqSendId(sendId));
result.delivered++;
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await this.db
.update(sends)
.set({
status: 'failed',
bounceReason: reason,
})
.where(eqSendId(sendId));
result.failed++;
result.errors.push({ email: recipient.email, reason });
}
}
return result;
}
}
// Small local helper so we don't depend on drizzle-orm's exported `eq`
// right here — keeps this file free of orm-plumbing imports beyond what
// it actually needs.
import { eq } from 'drizzle-orm';
function eqSendId(id: string) {
return eq(sends.id, id);
}

View file

@ -0,0 +1,78 @@
import { describe, it, expect } from 'bun:test';
import { signToken, verifyToken, generateNonce } from './tracking-token';
const SECRET = 'test-secret-never-in-prod';
const OTHER_SECRET = 'other-secret';
const payload = {
campaignId: 'camp-abc-123',
sendId: 'send-xyz-789',
nonce: 'n_test_nonce',
};
describe('tracking-token', () => {
it('signs and verifies a token roundtrip', () => {
const token = signToken(payload, SECRET);
const decoded = verifyToken(token, SECRET);
expect(decoded).toEqual(payload);
});
it('returns a URL-safe token (base64url, no padding)', () => {
const token = signToken(payload, SECRET);
expect(token).not.toContain('+');
expect(token).not.toContain('/');
expect(token).not.toContain('=');
expect(token).toContain('.');
});
it('rejects tokens signed with a different secret', () => {
const token = signToken(payload, OTHER_SECRET);
expect(verifyToken(token, SECRET)).toBeNull();
});
it('rejects tampered payload', () => {
const token = signToken(payload, SECRET);
// Flip a character in the payload half — signature no longer matches.
const [p, s] = token.split('.');
const tampered = `${p.slice(0, -1)}X.${s}`;
expect(verifyToken(tampered, SECRET)).toBeNull();
});
it('rejects tampered signature', () => {
const token = signToken(payload, SECRET);
const [p, s] = token.split('.');
const tampered = `${p}.${s.slice(0, -1)}X`;
expect(verifyToken(tampered, SECRET)).toBeNull();
});
it('rejects malformed tokens', () => {
expect(verifyToken('', SECRET)).toBeNull();
expect(verifyToken('only-one-part', SECRET)).toBeNull();
expect(verifyToken('a.b.c', SECRET)).toBeNull(); // three parts
expect(verifyToken('!@#.!@#', SECRET)).toBeNull(); // bad base64
});
it('handles special chars in ids through base64-encoding', () => {
const withDots = {
campaignId: 'camp:with:colons',
sendId: 'send.with.dots',
nonce: 'n_ok',
};
const token = signToken(withDots, SECRET);
const decoded = verifyToken(token, SECRET);
expect(decoded).toEqual(withDots);
});
it('generates unique nonces', () => {
const nonces = new Set<string>();
for (let i = 0; i < 100; i++) nonces.add(generateNonce());
expect(nonces.size).toBe(100);
});
it('generated nonces are URL-safe', () => {
for (let i = 0; i < 20; i++) {
const nonce = generateNonce();
expect(nonce).toMatch(/^[A-Za-z0-9_-]+$/);
}
});
});

View file

@ -0,0 +1,113 @@
/**
* Tracking-token signing / verification.
*
* Tokens travel in publicly-fetchable URLs (the open pixel, the click
* redirect, the unsubscribe link). If they were just raw IDs, anyone who
* guessed a campaign+send id pair could forge events or unsubscribe
* other people.
*
* The token is an HMAC-SHA256 over `{campaignId}:{sendId}:{nonce}`,
* base64url-encoded into a single opaque blob. The nonce is stored in
* the DB (broadcast.sends.tracking_nonce) so rotating the signing key
* doesn't invalidate existing tokens verification re-signs with the
* stored nonce and the current key.
*
* We don't use JWT here because:
* - JWT is overkill for a single-field payload
* - base64url-HMAC is ~50 chars vs JWT's ~150; better in a mailto
* - JWT's `alg` field adds an attack surface (alg=none, etc.)
*
* The signing key lives in BROADCAST_TRACKING_SECRET. Rotating it
* requires walking broadcast.sends and re-issuing tokens deferred
* until we actually need rotation (Phase 2+).
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
export interface TokenPayload {
campaignId: string;
sendId: string;
nonce: string;
}
/** Base64url-encode without padding (URL-safe). */
function base64url(buf: Buffer): string {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function base64urlDecode(s: string): Buffer {
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
return Buffer.from(padded + pad, 'base64');
}
/**
* Generate a fresh per-send nonce. 16 bytes (128 bits) enough entropy
* that even with 1M sends the collision chance is negligible.
*/
export function generateNonce(): string {
return base64url(randomBytes(16));
}
/**
* Sign a token. The returned string is url-safe and ready to drop into
* a mail template.
*
* Format: `base64url(JSON(payload)).base64url(hmac)`
*
* Inner JSON encoding means campaign / send ids can contain arbitrary
* characters (colons, dots, whatever) without breaking the parse the
* alternative, a delimiter-based raw string, puts a fragile escape
* dance on IDs that should just be opaque.
*
* Two sections separated by `.` familiar from JWT but without the
* header (we don't need algorithm agility).
*/
export function signToken(payload: TokenPayload, secret: string): string {
const json = JSON.stringify(payload);
const payloadPart = base64url(Buffer.from(json, 'utf8'));
const sig = createHmac('sha256', secret).update(payloadPart).digest();
const sigPart = base64url(sig);
return `${payloadPart}.${sigPart}`;
}
/**
* Verify + decode a token. Returns null on ANY failure invalid format,
* bad HMAC, truncated payload. Constant-time HMAC compare to avoid
* timing-side-channel on the secret.
*/
export function verifyToken(token: string, secret: string): TokenPayload | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [payloadPart, sigPart] = parts;
const expectedSig = createHmac('sha256', secret).update(payloadPart).digest();
let providedSig: Buffer;
try {
providedSig = base64urlDecode(sigPart);
} catch {
return null;
}
if (providedSig.length !== expectedSig.length) return null;
if (!timingSafeEqual(providedSig, expectedSig)) return null;
let parsed: unknown;
try {
const json = base64urlDecode(payloadPart).toString('utf8');
parsed = JSON.parse(json);
} catch {
return null;
}
if (
!parsed ||
typeof parsed !== 'object' ||
typeof (parsed as TokenPayload).campaignId !== 'string' ||
typeof (parsed as TokenPayload).sendId !== 'string' ||
typeof (parsed as TokenPayload).nonce !== 'string'
) {
return null;
}
const { campaignId, sendId, nonce } = parsed as TokenPayload;
if (!campaignId || !sendId || !nonce) return null;
return { campaignId, sendId, nonce };
}