managarten/services/mana-mail/src/index.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

98 lines
3.7 KiB
TypeScript

/**
* mana-mail — Mail service for the Mana ecosystem.
*
* Hono + Bun runtime. Provides JMAP-based email access to Stalwart,
* account provisioning (@mana.how addresses), and mail API for the frontend.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadConfig } from './config';
import { getDb } from './db/connection';
import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
import { jwtAuth } from './middleware/jwt-auth';
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';
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 ──────────────────────────────────────────────
const config = loadConfig();
const db = getDb(config.databaseUrl);
// Instantiate services
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,
config.broadcast.sendThrottleMs
);
// ─── App ────────────────────────────────────────────────────
const app = new Hono();
// Global middleware
app.onError(errorHandler);
app.use(
'*',
cors({
origin: config.cors.origins,
credentials: true,
})
);
// 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));
// Service-to-service routes (X-Service-Key auth)
app.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
app.route('/api/v1/internal', createInternalRoutes(accountService));
// ─── Start ──────────────────────────────────────────────────
console.log(`mana-mail starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};