mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 14:09:41 +02:00
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>
92 lines
2.9 KiB
TypeScript
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),
|
|
},
|
|
};
|
|
}
|