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:
parent
e614df1b54
commit
2d636f5f25
|
|
@ -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 } : {}),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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">✓</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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue