mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 15:59:40 +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>
|
||||
|
|
|
|||
90
pnpm-lock.yaml
generated
90
pnpm-lock.yaml
generated
|
|
@ -2792,6 +2792,9 @@ importers:
|
|||
jose:
|
||||
specifier: ^6.1.2
|
||||
version: 6.2.2
|
||||
juice:
|
||||
specifier: ^11.1.1
|
||||
version: 11.1.1
|
||||
postgres:
|
||||
specifier: ^3.4.5
|
||||
version: 3.4.9
|
||||
|
|
@ -9178,6 +9181,10 @@ packages:
|
|||
cheerio-select@2.1.0:
|
||||
resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
|
||||
|
||||
cheerio@1.0.0:
|
||||
resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==}
|
||||
engines: {node: '>=18.17'}
|
||||
|
||||
cheerio@1.2.0:
|
||||
resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
|
@ -10436,6 +10443,10 @@ packages:
|
|||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-goat@3.0.0:
|
||||
resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
|
|
@ -11699,6 +11710,9 @@ packages:
|
|||
htmlparser2@10.1.0:
|
||||
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
|
||||
|
||||
htmlparser2@9.1.0:
|
||||
resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==}
|
||||
|
||||
http-cache-semantics@4.2.0:
|
||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||
|
||||
|
|
@ -12411,6 +12425,11 @@ packages:
|
|||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
|
|
@ -12883,6 +12902,9 @@ packages:
|
|||
resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
mensch@0.3.4:
|
||||
resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==}
|
||||
|
||||
merge-descriptors@1.0.3:
|
||||
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
|
||||
|
||||
|
|
@ -13154,6 +13176,11 @@ packages:
|
|||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-fn@1.2.0:
|
||||
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -14942,6 +14969,9 @@ packages:
|
|||
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
slick@1.12.2:
|
||||
resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
|
||||
|
||||
slugify@1.6.9:
|
||||
resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
|
@ -15909,6 +15939,10 @@ packages:
|
|||
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
|
@ -16190,6 +16224,10 @@ packages:
|
|||
web-namespaces@2.0.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -23528,7 +23566,7 @@ snapshots:
|
|||
obug: 2.1.1
|
||||
std-env: 4.0.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':
|
||||
dependencies:
|
||||
|
|
@ -23590,7 +23628,7 @@ snapshots:
|
|||
sirv: 3.0.2
|
||||
tinyglobby: 0.2.15
|
||||
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':
|
||||
dependencies:
|
||||
|
|
@ -24953,6 +24991,20 @@ snapshots:
|
|||
domhandler: 5.0.3
|
||||
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:
|
||||
dependencies:
|
||||
cheerio-select: 2.1.0
|
||||
|
|
@ -26115,6 +26167,8 @@ snapshots:
|
|||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-goat@3.0.0: {}
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
escape-string-regexp@1.0.5: {}
|
||||
|
|
@ -27922,6 +27976,13 @@ snapshots:
|
|||
domutils: 3.2.2
|
||||
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-errors@2.0.1:
|
||||
|
|
@ -28898,6 +28959,15 @@ snapshots:
|
|||
object.assign: 4.1.7
|
||||
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:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
|
|
@ -29429,6 +29499,8 @@ snapshots:
|
|||
next-tick: 1.1.0
|
||||
timers-ext: 0.1.8
|
||||
|
||||
mensch@0.3.4: {}
|
||||
|
||||
merge-descriptors@1.0.3: {}
|
||||
|
||||
merge-descriptors@2.0.0: {}
|
||||
|
|
@ -30085,6 +30157,8 @@ snapshots:
|
|||
|
||||
mime@1.6.0: {}
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
mimic-fn@1.2.0: {}
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
|
@ -32354,6 +32428,8 @@ snapshots:
|
|||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
|
||||
slick@1.12.2: {}
|
||||
|
||||
slugify@1.6.9: {}
|
||||
|
||||
smob@1.6.1: {}
|
||||
|
|
@ -33325,6 +33401,8 @@ snapshots:
|
|||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
convert-source-map: 2.0.0
|
||||
|
||||
valid-data-url@3.0.1: {}
|
||||
|
||||
validate-npm-package-name@5.0.1: {}
|
||||
|
||||
validator@13.15.35: {}
|
||||
|
|
@ -33710,6 +33788,14 @@ snapshots:
|
|||
|
||||
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@4.0.0-beta.3: {}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,11 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@mana/shared-hono": "workspace:*",
|
||||
"hono": "^4.7.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"hono": "^4.7.0",
|
||||
"jose": "^6.1.2",
|
||||
"juice": "^11.1.1",
|
||||
"postgres": "^3.4.5",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,14 @@ export interface Config {
|
|||
cors: {
|
||||
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 {
|
||||
|
|
@ -60,5 +68,20 @@ export function loadConfig(): Config {
|
|||
cors: {
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
122
services/mana-mail/src/db/schema/broadcast.ts
Normal file
122
services/mana-mail/src/db/schema/broadcast.ts
Normal 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;
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './mail';
|
||||
export * from './broadcast';
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { serviceAuth } from './middleware/service-auth';
|
|||
import { JmapClient } from './services/jmap-client';
|
||||
import { AccountService } from './services/account-service';
|
||||
import { MailService } from './services/mail-service';
|
||||
import { BroadcastOrchestrator } from './services/broadcast-orchestrator';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createThreadRoutes } from './routes/threads';
|
||||
import { createMessageRoutes } from './routes/messages';
|
||||
|
|
@ -22,6 +23,9 @@ import { createSendRoutes } from './routes/send';
|
|||
import { createLabelRoutes } from './routes/labels';
|
||||
import { createAccountRoutes } from './routes/accounts';
|
||||
import { createInternalRoutes } from './routes/internal';
|
||||
import { createBroadcastSendRoutes } from './routes/broadcast-send';
|
||||
import { createBroadcastTrackRoutes } from './routes/broadcast-track';
|
||||
import { createBroadcastStatsRoutes } from './routes/broadcast-stats';
|
||||
|
||||
// ─── Bootstrap ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -32,6 +36,13 @@ const db = getDb(config.databaseUrl);
|
|||
const jmapClient = new JmapClient(config.stalwart);
|
||||
const accountService = new AccountService(db, config.stalwart);
|
||||
const mailService = new MailService(db, jmapClient, accountService);
|
||||
const broadcastOrchestrator = new BroadcastOrchestrator(
|
||||
db,
|
||||
jmapClient,
|
||||
accountService,
|
||||
config.broadcast.trackingSecret,
|
||||
config.baseUrl
|
||||
);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -50,10 +61,24 @@ app.use(
|
|||
// Health check (no auth)
|
||||
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)
|
||||
app.use('/api/v1/mail/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/mail', createThreadRoutes(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', createAccountRoutes(accountService));
|
||||
app.route('/api/v1/mail/messages', createMessageRoutes(mailService));
|
||||
|
|
|
|||
63
services/mana-mail/src/routes/broadcast-send.ts
Normal file
63
services/mana-mail/src/routes/broadcast-send.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
78
services/mana-mail/src/routes/broadcast-stats.ts
Normal file
78
services/mana-mail/src/routes/broadcast-stats.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
183
services/mana-mail/src/routes/broadcast-track.ts
Normal file
183
services/mana-mail/src/routes/broadcast-track.ts
Normal 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;
|
||||
}
|
||||
244
services/mana-mail/src/services/broadcast-orchestrator.ts
Normal file
244
services/mana-mail/src/services/broadcast-orchestrator.ts
Normal 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);
|
||||
}
|
||||
78
services/mana-mail/src/services/tracking-token.test.ts
Normal file
78
services/mana-mail/src/services/tracking-token.test.ts
Normal 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_-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
113
services/mana-mail/src/services/tracking-token.ts
Normal file
113
services/mana-mail/src/services/tracking-token.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue