diff --git a/services/mana-mail/src/config.ts b/services/mana-mail/src/config.ts index f9d3eeea4..1fe779705 100644 --- a/services/mana-mail/src/config.ts +++ b/services/mana-mail/src/config.ts @@ -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), }, }; } diff --git a/services/mana-mail/src/index.ts b/services/mana-mail/src/index.ts index 1ce12e440..2c1431134 100644 --- a/services/mana-mail/src/index.ts +++ b/services/mana-mail/src/index.ts @@ -41,7 +41,8 @@ const broadcastOrchestrator = new BroadcastOrchestrator( jmapClient, accountService, config.broadcast.trackingSecret, - config.baseUrl + config.baseUrl, + config.broadcast.sendThrottleMs ); // ─── App ──────────────────────────────────────────────────── diff --git a/services/mana-mail/src/services/broadcast-orchestrator.ts b/services/mana-mail/src/services/broadcast-orchestrator.ts index 027de08ec..a7a2d569e 100644 --- a/services/mana-mail/src/services/broadcast-orchestrator.ts +++ b/services/mana-mail/src/services/broadcast-orchestrator.ts @@ -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 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 . No-op for malformed // HTML — we still send. const pixel = ``; @@ -131,6 +151,10 @@ export class BroadcastOrchestrator { return { html, text: '', unsubscribeUrl, webViewUrl }; } + private sleep(ms: number): Promise { + 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; diff --git a/services/mana-mail/src/services/link-rewriter.test.ts b/services/mana-mail/src/services/link-rewriter.test.ts new file mode 100644 index 000000000..a0f68a7c1 --- /dev/null +++ b/services/mana-mail/src/services/link-rewriter.test.ts @@ -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(); + +describe('rewriteClickLinks', () => { + it('rewrites a simple https link', () => { + const { html, rewritten } = rewriteClickLinks( + 'click', + 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( + 'x', + TOKEN, + BASE, + EMPTY_SKIP + ); + expect(rewritten).toBe(1); + }); + + it('leaves mailto: alone', () => { + const input = 'mail'; + const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP); + expect(rewritten).toBe(0); + expect(html).toBe(input); + }); + + it('leaves tel: alone', () => { + const input = 'call'; + const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP); + expect(rewritten).toBe(0); + expect(html).toBe(input); + }); + + it('leaves anchor fragments alone', () => { + const input = 'down'; + 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 = `abbestellenother`; + 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( + 'x', + TOKEN, + BASE, + EMPTY_SKIP + ); + expect(html).toContain('class="btn"'); + expect(html).toContain('style="color:red"'); + }); + + it('counts multiple rewrites', () => { + const input = + 'a b x'; + const { rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP); + expect(rewritten).toBe(2); + }); + + it('handles single-quoted href attributes', () => { + const { rewritten, html } = rewriteClickLinks( + "x", + 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 = `x`; + 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( + '

Just some text, no links.

', + TOKEN, + BASE, + EMPTY_SKIP + ); + expect(rewritten).toBe(0); + expect(html).toBe('

Just some text, no links.

'); + }); +}); diff --git a/services/mana-mail/src/services/link-rewriter.ts b/services/mana-mail/src/services/link-rewriter.ts new file mode 100644 index 000000000..ad62d63ee --- /dev/null +++ b/services/mana-mail/src/services/link-rewriter.ts @@ -0,0 +1,51 @@ +/** + * HTML anchor-href rewriter for click tracking. + * + * Walks the body HTML and rewrites each `` 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 +): { 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 = /]*?)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 ``; + }); + + return { html: out, rewritten }; +}