From dc3cb37c2d8d150f05518f552f7734f3b916e5cc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 11 Nov 2026 09:07:04 +0000 Subject: [PATCH] feat: cross-promo block in the credits purchase receipt email Uses @profullstack/cross-promo (live catalog, bundled fallback, never throws): audience publishers, site crawlproof.com (never promotes itself), themed to the dark email shell, placed after the billing link and above the footer. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 ++ lib/email.ts | 31 ++++++++++++++++- package.json | 1 + tests/email-cross-promo.test.ts | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/email-cross-promo.test.ts diff --git a/bun.lock b/bun.lock index 8f93a8a3..377380c1 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@profullstack/autoblog": "^0.5.0", "@profullstack/coinpay": "^0.25.0", "@profullstack/coinpay-onboard": "^0.5.0", + "@profullstack/cross-promo": "^0.1.0", "@profullstack/fleetbar": "0.2.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", @@ -325,6 +326,8 @@ "@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.5.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-yqSx3pgJLP521Km+XNNn9fyjbbHppgozuGTtzTln9yy+2I0h0bjqXSpsFPwoSgVb5QcyBx+Wzv5170Qb7va1iQ=="], + "@profullstack/cross-promo": ["@profullstack/cross-promo@0.1.0", "", { "bin": { "cross-promo": "bin/cross-promo.js" } }, "sha512-LQMxZSvApH1T+8WmMu/Nj1LqojXeTw6wAtpLfhuKFwx9eR36hQG/7vVIDkKpmU5zKw7UwARe20MOfFCak2+ejA=="], + "@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="], "@profullstack/fleetbar": ["@profullstack/fleetbar@0.2.0", "", {}, "sha512-ImALhrAVZUnc0aqFW518WVZX895ffnLmChC/KXR6mPqONFinoLI2wwWASSN9n5wJrjsJShU6nTebrUXEX4GdCQ=="], diff --git a/lib/email.ts b/lib/email.ts index f6c9fc3e..7e422bd5 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -1,4 +1,5 @@ import { createEmailer, type Emailer } from "@profullstack/stack/email"; +import { promoBlock } from "@profullstack/cross-promo"; import { env } from "./env"; import type { SenderMailbox } from "./outreach/senderMailbox"; @@ -252,7 +253,14 @@ export function purchaseReceiptEmailHtml(input: { currency: string; paymentId: string; txHash?: string | null; + /** Cross-promo block (receiptPromo().html), shown after the receipt, above the footer. */ + promoHtml?: string; }): string { + const promoRow = input.promoHtml + ? ` + ${input.promoHtml} + ` + : ""; const txRow = input.txHash ? ` Tx @@ -307,7 +315,8 @@ export function purchaseReceiptEmailHtml(input: { A PDF copy of this receipt is attached for your records.

- `; + + ${promoRow}`; return emailShell({ title: `Your CrawlProof receipt`, innerHtml, @@ -445,6 +454,24 @@ export async function sendMarketingEmail(input: { return { sent: true }; } +// The "more from the network" block for a credits buyer: a publisher. crawlproof.com is the +// sender, so it never promotes itself. Themed to the dark email shell. Never throws. +export function receiptPromo(to: string) { + return promoBlock({ + site: "crawlproof.com", + audience: "publishers", + seed: to, + theme: { + accent: "#6ee7b7", + text: "#9aa3b2", + muted: "#64748b", + bg: "#0b0d10", + border: "#1f2630", + heading: "#e7e9ee", + }, + }); +} + export async function sendPurchaseReceiptEmail(input: { to: string; paymentId: string; @@ -463,6 +490,7 @@ export async function sendPurchaseReceiptEmail(input: { minimumFractionDigits: input.amountCents % 100 === 0 ? 0 : 2, })}`; const filename = `crawlproof-receipt-${input.paymentId.slice(0, 12)}.pdf`; + const promo = await receiptPromo(input.to); const res = await c.send({ from: env.resendFrom, @@ -475,6 +503,7 @@ export async function sendPurchaseReceiptEmail(input: { currency: input.currency, paymentId: input.paymentId, txHash: input.txHash, + promoHtml: promo.html, }), attachments: [{ filename, content: input.pdf.toString("base64") }], }); diff --git a/package.json b/package.json index f4761e0c..f4e3cd3e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@profullstack/autoblog": "^0.5.0", "@profullstack/coinpay": "^0.25.0", "@profullstack/coinpay-onboard": "^0.5.0", + "@profullstack/cross-promo": "^0.1.0", "@profullstack/fleetbar": "0.2.0", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.0", diff --git a/tests/email-cross-promo.test.ts b/tests/email-cross-promo.test.ts new file mode 100644 index 00000000..faa3fcd5 --- /dev/null +++ b/tests/email-cross-promo.test.ts @@ -0,0 +1,61 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +// The cross-promo block reads its bundled catalog, never the CDN, in tests. +process.env.CROSS_PROMO = "bundled"; +process.env.RESEND_API_KEY ||= "re_test"; + +const sent: Array<{ to: string; subject: string; html: string }> = []; +vi.mock("@profullstack/stack/email", () => ({ + createEmailer: () => ({ + send: vi.fn(async (m: { to: string; subject: string; html: string }) => { + sent.push(m); + return { sent: true }; + }), + }), +})); + +let email: typeof import("@/lib/email"); +beforeAll(async () => { + email = await import("@/lib/email"); +}); + +describe("purchase receipt cross-promo", () => { + it("promotes publisher sites to a credits buyer, never crawlproof.com itself", async () => { + const promo = await email.receiptPromo("buyer@example.com"); + expect(promo.items.length).toBeGreaterThan(0); + expect(promo.items.map((i) => i.id)).toContain("rssamplifier.com"); + expect(promo.items.map((i) => i.id)).not.toContain("crawlproof.com"); + expect(promo.html).not.toContain('href="https://crawlproof.com'); + expect(promo.html).toContain("utm_source=crawlproof.com"); + }); + + it("puts the block in the receipt after the billing link", async () => { + const res = await email.sendPurchaseReceiptEmail({ + to: "buyer@example.com", + paymentId: "pay_1234567890abcdef", + packLabel: "Starter", + creditsAdded: 10, + amountCents: 1000, + currency: "USD", + completedAt: new Date().toISOString(), + pdf: Buffer.from("%PDF-1.4"), + }); + expect(res.sent).toBe(true); + const html = sent.at(-1)!.html; + expect(html).toContain("Grow your readership"); + expect(html).toContain("https://rssamp.com"); + expect(html).not.toContain('href="https://crawlproof.com/?utm'); + expect(html.indexOf("View billing")).toBeLessThan(html.indexOf("Grow your readership")); + }); + + it("renders the receipt unchanged without a promo", () => { + const html = email.purchaseReceiptEmailHtml({ + packLabel: "Starter", + creditsAdded: 1, + amountFormatted: "$1", + currency: "USD", + paymentId: "pay_x", + }); + expect(html).not.toContain("Grow your readership"); + }); +});