managarten/services/mana-mail/src/config.ts
Till JS a312d98f09 feat(broadcast): click-link tracking + send throttle
Closes the last two dogfood blockers before real-campaign use.

link-rewriter.ts
- rewriteClickLinks(): walks <a href="http…"> in the HTML body and
  replaces each URL with /api/v1/track/click/{token}?url={original}
  so clicks go through the tracking endpoint. Regex-based because
  Tiptap output is well-formed; returns a count for debugging.
- Leaves mailto: / tel: / anchor fragments alone — wrapping those
  breaks the recipient's native handler and accomplishes nothing.
- `skipUrls` param carries the unsubscribe + web-view URLs (already
  tracking endpoints themselves) so they don't get double-wrapped.
- 11 unit tests covering http/https rewriting, skip list, non-http
  schemes, attribute preservation, multi-link count, quoted-attr
  variants, idempotency.

Orchestrator wiring
- substituteUrls now calls rewriteClickLinks after the preview-
  placeholder swap and before the open-pixel injection. The
  unsubscribe + web-view URLs from this same function are passed
  in as skip entries so they survive the pass untouched.
- Constructor gains `sendThrottleMs` param (default 150ms).
- Main send loop awaits sleep(throttleMs) between iterations. 150ms
  = ~6/sec = ~360/min, safely below most SMTP provider limits.
  100-recipient campaign = ~15s extra wall-clock but that's fine
  for MVP (and most campaigns are way smaller).

Config
- New env BROADCAST_SEND_THROTTLE_MS (default 150). Wired from
  loadConfig to the orchestrator constructor.

The broadcast module is now functionally complete for dogfooding.
Remaining before a real campaign can actually go out: run
`cd services/mana-mail && bun run db:push` to materialise the
broadcast.* schema tables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:07:58 +02:00

92 lines
2.9 KiB
TypeScript

/**
* Application configuration loaded from environment variables.
*/
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
serviceKey: string;
baseUrl: string;
stalwart: {
jmapUrl: string;
adminUser: string;
adminPassword: string;
domain: string;
};
smtp: {
host: string;
port: number;
user: string;
password: string;
from: string;
insecureTls: boolean;
};
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;
/** Sleep between JMAP submits during bulk-send. Protects Stalwart
* + downstream relays from being hammered. Set via env var
* BROADCAST_SEND_THROTTLE_MS (default 150ms). */
sendThrottleMs: number;
};
}
export function loadConfig(): Config {
const requiredEnv = (key: string, fallback?: string): string => {
const value = process.env[key] || fallback;
if (!value) throw new Error(`Missing required env var: ${key}`);
return value;
};
return {
port: parseInt(process.env.PORT || '3042', 10),
databaseUrl: requiredEnv(
'DATABASE_URL',
'postgresql://mana:devpassword@localhost:5432/mana_platform'
),
manaAuthUrl: requiredEnv('MANA_AUTH_URL', 'http://localhost:3001'),
serviceKey: requiredEnv('MANA_SERVICE_KEY', 'dev-service-key'),
baseUrl: requiredEnv('BASE_URL', 'http://localhost:3042'),
stalwart: {
jmapUrl: requiredEnv('STALWART_JMAP_URL', 'http://localhost:8080'),
adminUser: requiredEnv('STALWART_ADMIN_USER', 'admin'),
adminPassword: requiredEnv('STALWART_ADMIN_PASSWORD', 'ChangeMe123!'),
domain: requiredEnv('MAIL_DOMAIN', 'mana.how'),
},
smtp: {
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT || '587', 10),
user: process.env.SMTP_USER || 'noreply',
password: process.env.SMTP_PASSWORD || '',
from: process.env.SMTP_FROM || 'Mana <noreply@mana.how>',
insecureTls: process.env.SMTP_INSECURE_TLS === 'true',
},
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),
sendThrottleMs: parseInt(process.env.BROADCAST_SEND_THROTTLE_MS || '150', 10),
},
};
}