diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts index bcae897..0d7a9ee 100644 --- a/modules/rcart/components/folk-payment-page.ts +++ b/modules/rcart/components/folk-payment-page.ts @@ -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 } : {}), }), diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 5f39ced..66bbd07 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -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(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 = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' }; +const CHAIN_EXPLORERS: Record = { + 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 + ? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}` + : (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 = ` + + + + +
+ + + + + + + + + + + +
+
+

Payment Received

+
+ + + + + + + + + + + + + +
Amount${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}
Network${chainName}
Method${p.paymentMethod || 'N/A'}
Transaction${txLink}
Date${paidDate}
+ + +
+

What happens next

+

+ Your contribution flows into a funding flow that distributes resources across the project. + Track how funds are allocated in real time. +

+ View rFlows +
+ + +
+

Resources

+

+ Interplanetary Coordination System +

+

+ Endosymbiotic Finance (coming soon) +

+
+ +
+

+ Sent by rSpace +

+
+
+`; + + 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 ', + to: email, + subject: `Payment confirmed \u2014 ${p.amount} ${p.token}`, + html, + text, + }); +} + function paymentToResponse(p: PaymentRequestMeta) { return { id: p.id,