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>
This commit is contained in:
Till JS 2026-04-21 15:07:58 +02:00
parent d887fc125d
commit a312d98f09
5 changed files with 199 additions and 2 deletions

View file

@ -32,6 +32,10 @@ export interface Config {
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;
};
}
@ -82,6 +86,7 @@ export function loadConfig(): Config {
10
),
maxRecipientsPerHour: parseInt(process.env.BROADCAST_MAX_RECIPIENTS_PER_HOUR || '500', 10),
sendThrottleMs: parseInt(process.env.BROADCAST_SEND_THROTTLE_MS || '150', 10),
},
};
}

View file

@ -41,7 +41,8 @@ const broadcastOrchestrator = new BroadcastOrchestrator(
jmapClient,
accountService,
config.broadcast.trackingSecret,
config.baseUrl
config.baseUrl,
config.broadcast.sendThrottleMs
);
// ─── App ────────────────────────────────────────────────────

View file

@ -21,6 +21,7 @@ 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';
import { rewriteClickLinks } from './link-rewriter';
export interface BulkRecipient {
email: string;
@ -60,7 +61,15 @@ export class BroadcastOrchestrator {
private jmap: JmapClient,
private accountService: AccountService,
private trackingSecret: string,
private baseUrl: string
private baseUrl: string,
/**
* Milliseconds to sleep between JMAP submits. Protects the user's
* Stalwart + any downstream relay from being hammered by a 5000-
* recipient campaign. Default 150ms = ~6/sec = ~360/min, safely
* below most provider rate limits without making a 50-person
* newsletter feel slow.
*/
private sendThrottleMs: number = 150
) {}
/**
@ -119,6 +128,17 @@ export class BroadcastOrchestrator {
let html = replaceAll(inlinedHtml);
// Rewrite <a href="http(s)://…"> to go through the click-tracking
// endpoint. The already-substituted unsubscribe + web-view URLs
// are themselves tracking endpoints and must not be double-wrapped.
const rewriteResult = rewriteClickLinks(
html,
token,
this.baseUrl,
new Set([unsubscribeUrl, webViewUrl])
);
html = rewriteResult.html;
// Inject the open pixel just before </body>. No-op for malformed
// HTML — we still send.
const pixel = `<img src="${openPixelUrl}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px;">`;
@ -131,6 +151,10 @@ export class BroadcastOrchestrator {
return { html, text: '', unsubscribeUrl, webViewUrl };
}
private sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/**
* Fetch the set of email addresses this user's audience has
* unsubscribed from previous campaigns. Lowercased so a later case-
@ -277,6 +301,13 @@ export class BroadcastOrchestrator {
result.failed++;
result.errors.push({ email: recipient.email, reason });
}
// Throttle between sends so the loop doesn't DDoS our own
// Stalwart + any downstream relay. Skip the sleep on the
// last iteration — nobody's watching after the final send.
if (this.sendThrottleMs > 0) {
await this.sleep(this.sendThrottleMs);
}
}
return result;

View file

@ -0,0 +1,109 @@
import { describe, it, expect } from 'bun:test';
import { rewriteClickLinks } from './link-rewriter';
const TOKEN = 'abc.def';
const BASE = 'https://mail.mana.how';
const EMPTY_SKIP = new Set<string>();
describe('rewriteClickLinks', () => {
it('rewrites a simple https link', () => {
const { html, rewritten } = rewriteClickLinks(
'<a href="https://example.com">click</a>',
TOKEN,
BASE,
EMPTY_SKIP
);
expect(rewritten).toBe(1);
expect(html).toContain(`${BASE}/api/v1/track/click/${TOKEN}?url=`);
expect(html).toContain(encodeURIComponent('https://example.com'));
});
it('rewrites http:// links too', () => {
const { rewritten } = rewriteClickLinks(
'<a href="http://old-site.ch">x</a>',
TOKEN,
BASE,
EMPTY_SKIP
);
expect(rewritten).toBe(1);
});
it('leaves mailto: alone', () => {
const input = '<a href="mailto:foo@bar.ch">mail</a>';
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
expect(rewritten).toBe(0);
expect(html).toBe(input);
});
it('leaves tel: alone', () => {
const input = '<a href="tel:+41443000000">call</a>';
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
expect(rewritten).toBe(0);
expect(html).toBe(input);
});
it('leaves anchor fragments alone', () => {
const input = '<a href="#section-2">down</a>';
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
expect(rewritten).toBe(0);
expect(html).toBe(input);
});
it('skips URLs listed in skipUrls (unsubscribe, web-view)', () => {
const unsub = 'https://mail.mana.how/api/v1/track/unsubscribe/xxx.yyy';
const input = `<a href="${unsub}">abbestellen</a><a href="https://other.ch">other</a>`;
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, new Set([unsub]));
expect(rewritten).toBe(1);
expect(html).toContain(unsub); // untouched
expect(html).toContain(encodeURIComponent('https://other.ch')); // rewritten
});
it('preserves other attributes on the anchor', () => {
const { html } = rewriteClickLinks(
'<a class="btn" href="https://example.com" style="color:red">x</a>',
TOKEN,
BASE,
EMPTY_SKIP
);
expect(html).toContain('class="btn"');
expect(html).toContain('style="color:red"');
});
it('counts multiple rewrites', () => {
const input =
'<a href="https://a.ch">a</a> <a href="https://b.ch">b</a> <a href="mailto:x@y.z">x</a>';
const { rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
expect(rewritten).toBe(2);
});
it('handles single-quoted href attributes', () => {
const { rewritten, html } = rewriteClickLinks(
"<a href='https://example.com'>x</a>",
TOKEN,
BASE,
EMPTY_SKIP
);
expect(rewritten).toBe(1);
// Output should still be single-quoted.
expect(html).toContain("href='");
});
it('is idempotent for skip URLs — passing them again does not double-wrap', () => {
const wrapped = `${BASE}/api/v1/track/click/${TOKEN}?url=${encodeURIComponent('https://x.ch')}`;
const input = `<a href="${wrapped}">x</a>`;
const { rewritten, html } = rewriteClickLinks(input, TOKEN, BASE, new Set([wrapped]));
expect(rewritten).toBe(0);
expect(html).toBe(input);
});
it('returns count even when no links match', () => {
const { rewritten, html } = rewriteClickLinks(
'<p>Just some text, no links.</p>',
TOKEN,
BASE,
EMPTY_SKIP
);
expect(rewritten).toBe(0);
expect(html).toBe('<p>Just some text, no links.</p>');
});
});

View file

@ -0,0 +1,51 @@
/**
* HTML anchor-href rewriter for click tracking.
*
* Walks the body HTML and rewrites each `<a href="http…">` to
* `.../api/v1/track/click/{token}?url={encoded_original}` so clicks go
* through the tracking endpoint first. Non-http schemes (mailto:, tel:,
* sms:, anchor fragments) are left alone tracking them is both
* pointless and potentially harmful (mailto: tracking would break the
* recipient's mail client hand-off).
*
* URLs listed in `skipUrls` are passed through untouched. That's how
* the unsubscribe-URL and web-view-URL (already signed tracking URLs)
* avoid double-wrapping.
*
* Regex-based because Tiptap's output is well-formed HTML — we don't
* need a full parser. If users ever get to paste arbitrary HTML, we
* swap to parse5.
*/
/**
* Replace anchor href attributes in the HTML body with tracked URLs.
* Returns the rewritten HTML plus a count of how many links were
* touched (useful for stats / debugging).
*/
export function rewriteClickLinks(
html: string,
token: string,
baseUrl: string,
skipUrls: Set<string>
): { html: string; rewritten: number } {
let rewritten = 0;
const trackBase = `${baseUrl}/api/v1/track/click/${token}`;
// Match only anchor tags — image src / form action aren't clicks.
// The pattern is deliberately loose on whitespace and attribute
// order to survive minor Tiptap formatting variations.
const pattern = /<a\b([^>]*?)href=(["'])([^"']+)\2([^>]*)>/gi;
const out = html.replace(pattern, (match, preAttrs, quote, url, postAttrs) => {
// Keep non-http(s) untouched: mailto / tel / anchor fragments
// should land in the native handler, not the tracker.
if (!/^https?:\/\//i.test(url)) return match;
if (skipUrls.has(url)) return match;
const wrappedUrl = `${trackBase}?url=${encodeURIComponent(url)}`;
rewritten++;
return `<a${preAttrs}href=${quote}${wrappedUrl}${quote}${postAttrs}>`;
});
return { html: out, rewritten };
}