managarten/services/mana-mail/package.json
Till JS f17383f9f2 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>
2026-04-21 13:53:13 +02:00

26 lines
569 B
JSON

{
"name": "@mana/mail-service",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@mana/shared-hono": "workspace:*",
"drizzle-orm": "^0.38.3",
"hono": "^4.7.0",
"jose": "^6.1.2",
"juice": "^11.1.1",
"postgres": "^3.4.5",
"zod": "^3.24.0"
},
"devDependencies": {
"drizzle-kit": "^0.30.4",
"typescript": "^5.9.3"
}
}