Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 30 additions & 1 deletion lib/email.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
? `<tr>
<td style="padding:0 32px 24px;">${input.promoHtml}</td>
</tr>`
: "";
const txRow = input.txHash
? `<tr>
<td style="padding:6px 0;color:#64748b;font-size:11px;text-transform:uppercase;letter-spacing:.1em;">Tx</td>
Expand Down Expand Up @@ -307,7 +315,8 @@ export function purchaseReceiptEmailHtml(input: {
A PDF copy of this receipt is attached for your records.
</p>
</td>
</tr>`;
</tr>
${promoRow}`;
return emailShell({
title: `Your CrawlProof receipt`,
innerHtml,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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") }],
});
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 61 additions & 0 deletions tests/email-cross-promo.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});