diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/api.ts b/apps/mana/apps/web/src/lib/modules/broadcast/api.ts new file mode 100644 index 000000000..204af4c82 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/broadcast/api.ts @@ -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 { + // 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 { + 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 { + 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, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/broadcast/views/ComposeView.svelte b/apps/mana/apps/web/src/lib/modules/broadcast/views/ComposeView.svelte index eb79d4887..809e2d7bf 100644 --- a/apps/mana/apps/web/src/lib/modules/broadcast/views/ComposeView.svelte +++ b/apps/mana/apps/web/src/lib/modules/broadcast/views/ComposeView.svelte @@ -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'; + } + }
@@ -245,15 +304,85 @@ {/if} {:else if step === 4} -
-
-

Senden

-

- Der Bulk-Send-Flow (Jetzt / Später) landet in M4 sobald mana-mail's /bulk-send-Endpoint steht. -

- -
+
+ {#if sendState === 'idle'} +
+

Jetzt senden

+

+ {audience.estimatedCount} Empfänger erhalten die Kampagne + „{subject}" + von {fromName}. +

+

+ 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). +

+
+ + +
+
+ {:else if sendState === 'confirming'} +
+

Sicher?

+

+ Die Kampagne geht an {audience.estimatedCount} Empfänger. Nach dem Versand + kannst du nichts mehr ändern — wenn dir ein Fehler auffällt, musst du eine neue Kampagne als + Korrektur schicken. +

+
+ + +
+
+ {:else if sendState === 'sending'} +
+
+

Versand läuft …

+

+ Wir schicken {audience.estimatedCount} Mails raus. Bitte Fenster offen lassen. +

+
+ {:else if sendState === 'done' && sendResult} +
+
+

Versand abgeschlossen

+

+ {sendResult.delivered} Mails versendet + {#if sendResult.failed > 0} + · {sendResult.failed} Fehler + {/if} +

+ {#if sendResult.errors.length > 0} +
+ Fehler anzeigen ({sendResult.errors.length}) +
    + {#each sendResult.errors as err (err.email)} +
  • {err.email} — {err.reason}
  • + {/each} +
+
+ {/if} +
+ +
+
+ {/if}
{/if}
@@ -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; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 107901b20..65283fe2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/services/mana-mail/package.json b/services/mana-mail/package.json index 86e66a0eb..fb1553ef5 100644 --- a/services/mana-mail/package.json +++ b/services/mana-mail/package.json @@ -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": { diff --git a/services/mana-mail/src/config.ts b/services/mana-mail/src/config.ts index c53b6b8a7..f9d3eeea4 100644 --- a/services/mana-mail/src/config.ts +++ b/services/mana-mail/src/config.ts @@ -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), + }, }; } diff --git a/services/mana-mail/src/db/schema/broadcast.ts b/services/mana-mail/src/db/schema/broadcast.ts new file mode 100644 index 000000000..ef9507bfc --- /dev/null +++ b/services/mana-mail/src/db/schema/broadcast.ts @@ -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; diff --git a/services/mana-mail/src/db/schema/index.ts b/services/mana-mail/src/db/schema/index.ts index efc83f65a..893f4146e 100644 --- a/services/mana-mail/src/db/schema/index.ts +++ b/services/mana-mail/src/db/schema/index.ts @@ -1 +1,2 @@ export * from './mail'; +export * from './broadcast'; diff --git a/services/mana-mail/src/index.ts b/services/mana-mail/src/index.ts index 7157a0015..1ce12e440 100644 --- a/services/mana-mail/src/index.ts +++ b/services/mana-mail/src/index.ts @@ -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)); diff --git a/services/mana-mail/src/routes/broadcast-send.ts b/services/mana-mail/src/routes/broadcast-send.ts new file mode 100644 index 000000000..f9dd586fd --- /dev/null +++ b/services/mana-mail/src/routes/broadcast-send.ts @@ -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); + }); +} diff --git a/services/mana-mail/src/routes/broadcast-stats.ts b/services/mana-mail/src/routes/broadcast-stats.ts new file mode 100644 index 000000000..e3752303e --- /dev/null +++ b/services/mana-mail/src/routes/broadcast-stats.ts @@ -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`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`count(distinct ${events.sendId})::int`, + totalCount: sql`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; + + 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(), + }); + }); +} diff --git a/services/mana-mail/src/routes/broadcast-track.ts b/services/mana-mail/src/routes/broadcast-track.ts new file mode 100644 index 000000000..6212434c0 --- /dev/null +++ b/services/mana-mail/src/routes/broadcast-track.ts @@ -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( + '

Ungültiger Abmelde-Link

Der Link ist entweder abgelaufen oder wurde manipuliert.

', + 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( + 'Abgemeldet' + + '' + + '

Du wurdest abgemeldet

' + + '

Du bekommst von uns keine weiteren Newsletter mehr.

' + + '

Falls das ein Versehen war, antworte einfach auf eine unserer letzten E-Mails — wir kümmern uns darum.

' + + '' + ); + }); + + /** + * 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; +} diff --git a/services/mana-mail/src/services/broadcast-orchestrator.ts b/services/mana-mail/src/services/broadcast-orchestrator.ts new file mode 100644 index 000000000..68ea36a1e --- /dev/null +++ b/services/mana-mail/src/services/broadcast-orchestrator.ts @@ -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 `