mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
d887fc125d
commit
a312d98f09
5 changed files with 199 additions and 2 deletions
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ const broadcastOrchestrator = new BroadcastOrchestrator(
|
|||
jmapClient,
|
||||
accountService,
|
||||
config.broadcast.trackingSecret,
|
||||
config.baseUrl
|
||||
config.baseUrl,
|
||||
config.broadcast.sendThrottleMs
|
||||
);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
109
services/mana-mail/src/services/link-rewriter.test.ts
Normal file
109
services/mana-mail/src/services/link-rewriter.test.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
51
services/mana-mail/src/services/link-rewriter.ts
Normal file
51
services/mana-mail/src/services/link-rewriter.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue