mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +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 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 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>
|
</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
90
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 './mail';
|
||||||
|
export * from './broadcast';
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
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