feat(rcart): send branded payment success email on completed payments

Sends an HTML email with payment details, rFlows CTA, and resource links
when a card/Transak payment completes. Fire-and-forget (never blocks response).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 12:53:58 -07:00
parent e614df1b54
commit 2d636f5f25
2 changed files with 155 additions and 1 deletions

View File

@ -359,6 +359,7 @@ class FolkPaymentPage extends HTMLElement {
paymentMethod: method,
txHash: txHash || undefined,
transakOrderId: transakOrderId || undefined,
payerEmail: this.cardEmail || undefined,
// When payer_choice, tell server what the payer chose
...(p?.paymentType === 'payer_choice' ? { chosenPaymentType: this.chosenPaymentType } : {}),
}),

View File

@ -32,9 +32,30 @@ import {
import { extractProductFromUrl } from './extract';
import { createTransakWidgetUrl, extractRootDomain, getTransakApiKey } from '../../shared/transak';
import QRCode from 'qrcode';
import { createTransport, type Transporter } from "nodemailer";
let _syncServer: SyncServer | null = null;
// ── SMTP transport (lazy init) ──
let _smtpTransport: Transporter | null = null;
function getSmtpTransport(): Transporter | null {
if (_smtpTransport) return _smtpTransport;
if (!process.env.SMTP_PASS) return null;
_smtpTransport = createTransport({
host: process.env.SMTP_HOST || "mail.rmail.online",
port: Number(process.env.SMTP_PORT) || 587,
secure: Number(process.env.SMTP_PORT) === 465,
auth: {
user: process.env.SMTP_USER || "noreply@rmail.online",
pass: process.env.SMTP_PASS,
},
tls: { rejectUnauthorized: false },
});
return _smtpTransport;
}
const routes = new Hono();
// Provider registry URL (for fulfillment resolution)
@ -1286,7 +1307,7 @@ routes.patch("/api/payments/:id/status", async (c) => {
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const body = await c.req.json();
const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount, chosenPaymentType } = body;
const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount, chosenPaymentType, payerEmail } = body;
const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled'];
if (status && !validStatuses.includes(status)) {
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
@ -1330,6 +1351,14 @@ routes.patch("/api/payments/:id/status", async (c) => {
});
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
// Fire-and-forget payment success email
if (status === 'paid' && payerEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payerEmail)) {
const host = c.req.header("host") || "rspace.online";
sendPaymentSuccessEmail(payerEmail, updated!.payment, host, space)
.catch((err) => console.error('[rcart] payment email failed:', err));
}
return c.json(paymentToResponse(updated!.payment));
});
@ -1400,6 +1429,130 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
return c.json({ widgetUrl });
});
// ── Payment success email ──
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const CHAIN_EXPLORERS: Record<number, string> = {
8453: 'https://basescan.org/tx/',
84532: 'https://sepolia.basescan.org/tx/',
1: 'https://etherscan.io/tx/',
};
async function sendPaymentSuccessEmail(
email: string,
p: PaymentRequestMeta,
host: string,
space: string,
) {
const transport = getSmtpTransport();
if (!transport) {
console.warn('[rcart] SMTP not configured — skipping payment email');
return;
}
const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
const explorer = CHAIN_EXPLORERS[p.chainId];
const txLink = explorer && p.txHash
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
: (p.txHash || 'N/A');
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
const rflowsUrl = `https://${host}/${space}/rflows`;
const dashboardUrl = `https://${host}/${space}/rcart`;
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<div style="font-size:48px;margin-bottom:8px">&#10003;</div>
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">Payment Received</h1>
</td></tr>
<!-- Body -->
<tr><td style="background:#1a1a24;padding:28px 24px">
<!-- Details table -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Method</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${p.paymentMethod || 'N/A'}</td></tr>
<tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
<tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
<td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
</table>
<!-- What happens next -->
<div style="background:#12121a;border:1px solid #2a2a3a;border-radius:8px;padding:20px;margin-bottom:24px">
<h2 style="margin:0 0 8px;color:#f0f0f5;font-size:16px;font-weight:600">What happens next</h2>
<p style="margin:0 0 16px;color:#9ca3af;font-size:14px;line-height:1.6">
Your contribution flows into a funding flow that distributes resources across the project.
Track how funds are allocated in real time.
</p>
<a href="${rflowsUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View rFlows</a>
</div>
<!-- Resources -->
<div style="margin-bottom:8px">
<h2 style="margin:0 0 10px;color:#f0f0f5;font-size:16px;font-weight:600">Resources</h2>
<p style="margin:0 0 6px;font-size:14px;line-height:1.6">
<a href="https://psilo-cyber.net/ics" style="color:#67e8f9;text-decoration:none">Interplanetary Coordination System</a>
</p>
<p style="margin:0;color:#6b7280;font-size:13px;line-height:1.6">
Endosymbiotic Finance (coming soon)
</p>
</div>
</td></tr>
<!-- Footer -->
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
<p style="margin:0;color:#6b7280;font-size:12px">
Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
const text = [
`Payment Received`,
``,
`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
`Network: ${chainName}`,
`Method: ${p.paymentMethod || 'N/A'}`,
`Transaction: ${p.txHash || 'N/A'}`,
`Date: ${paidDate}`,
``,
`What happens next:`,
`Your contribution flows into a funding flow that distributes resources across the project.`,
`View rFlows: ${rflowsUrl}`,
``,
`Resources:`,
`Interplanetary Coordination System: https://psilo-cyber.net/ics`,
`Endosymbiotic Finance (coming soon)`,
``,
`Sent by rSpace — ${dashboardUrl}`,
].join('\n');
await transport.sendMail({
from: 'rSpace <noreply@rspace.online>',
to: email,
subject: `Payment confirmed \u2014 ${p.amount} ${p.token}`,
html,
text,
});
}
function paymentToResponse(p: PaymentRequestMeta) {
return {
id: p.id,