feat: payment email notifications, GLB viewer, and EncryptID email lookup

- Add EncryptID internal endpoint for email lookup by userId
- rcart: send "Payment Sent" to payer and "Payment Received" to recipient
- rcart: resolve emails via EncryptID when not provided in request
- rsplat: add GLB/GLTF 3D viewer using Three.js GLTFLoader
- rsplat: enable publicWrite for photo uploads without space membership
- docker-compose: add SITE_URL and SPLAT_NOTIFY_EMAIL env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 04:02:07 +00:00
parent 44e7639124
commit ccca8318a3
5 changed files with 267 additions and 34 deletions

View File

@ -42,6 +42,8 @@ services:
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-noreply@rmail.online}
- SMTP_PASS=${SMTP_PASS}
- SITE_URL=https://rspace.online
- SPLAT_NOTIFY_EMAIL=jeffemmett@gmail.com
- TWENTY_API_URL=http://twenty-ch-server:3000
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN:-}
- TRANSAK_API_KEY=${TRANSAK_API_KEY:-}

View File

@ -59,6 +59,21 @@ function getSmtpTransport(): Transporter | null {
return _smtpTransport;
}
// ── EncryptID internal email lookup ──
const ENCRYPTID_INTERNAL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";
async function lookupEncryptIDEmail(userId: string): Promise<{ email: string | null; username: string | null }> {
try {
const res = await fetch(`${ENCRYPTID_INTERNAL}/api/internal/user-email/${encodeURIComponent(userId)}`);
if (!res.ok) return { email: null, username: null };
return await res.json();
} catch (e) {
console.warn('[rcart] EncryptID email lookup failed:', e);
return { email: null, username: null };
}
}
const routes = new Hono();
// Provider registry URL (for fulfillment resolution)
@ -1641,11 +1656,33 @@ 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)) {
// Fire-and-forget payment emails to both payer and recipient
if (status === 'paid') {
const host = c.req.header("host") || "rspace.online";
sendPaymentSuccessEmail(payerEmail, updated!.payment, host, space)
.catch((err) => console.error('[rcart] payment email failed:', err));
const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
// Resolve payer email: from request body, or look up via EncryptID
(async () => {
let resolvedPayerEmail = payerEmail;
if (!resolvedPayerEmail && payerIdentity) {
const lookup = await lookupEncryptIDEmail(payerIdentity);
if (lookup.email) resolvedPayerEmail = lookup.email;
}
// Send "Payment Sent" email to payer
if (resolvedPayerEmail && isValidEmail(resolvedPayerEmail)) {
sendPaymentSuccessEmail(resolvedPayerEmail, updated!.payment, host, space)
.catch((err) => console.error('[rcart] payer email failed:', err));
}
// Send "Payment Received" email to recipient (creator)
const creatorDid = updated!.payment.creatorDid;
if (creatorDid) {
const creatorLookup = await lookupEncryptIDEmail(creatorDid);
if (creatorLookup.email && isValidEmail(creatorLookup.email)) {
sendPaymentReceivedEmail(creatorLookup.email, updated!.payment, host, space, resolvedPayerEmail)
.catch((err) => console.error('[rcart] recipient email failed:', err));
}
}
})().catch((err) => console.error('[rcart] payment email resolution failed:', err));
}
// Auto-record contribution on linked shopping cart
@ -2091,6 +2128,83 @@ async function sendPaymentSuccessEmail(
});
}
async function sendPaymentReceivedEmail(
email: string,
p: PaymentRequestMeta,
host: string,
space: string,
payerEmail?: string,
) {
const transport = getSmtpTransport();
if (!transport) 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 dashboardUrl = `https://${host}/${space}/rcart`;
const payerLabel = payerEmail || p.payerIdentity?.slice(0, 10) + '...' || 'Anonymous';
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%">
<tr><td style="background:linear-gradient(135deg,#10b981,#06b6d4);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<div style="font-size:48px;margin-bottom:8px">&#128176;</div>
<h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">You Received a Payment</h1>
</td></tr>
<tr><td style="background:#1a1a24;padding:28px 24px">
<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">From</td>
<td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${payerLabel}</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">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>
<div style="text-align:center;margin-bottom:8px">
<a href="${dashboardUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#10b981,#06b6d4);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View Dashboard</a>
</div>
</td></tr>
<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 = [
`You Received a Payment`,
``,
`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
`From: ${payerLabel}`,
`Network: ${chainName}`,
`Transaction: ${p.txHash || 'N/A'}`,
`Date: ${paidDate}`,
``,
`View Dashboard: ${dashboardUrl}`,
``,
`Sent by rSpace — ${dashboardUrl}`,
].join('\n');
await transport.sendMail({
from: 'rSpace <noreply@rspace.online>',
to: email,
subject: `Payment received \u2014 ${p.amount} ${p.token}`,
html,
text,
});
console.log(`[rcart] Payment received email sent to ${email}`);
}
function paymentToResponse(p: PaymentRequestMeta) {
return {
id: p.id,

View File

@ -623,37 +623,14 @@ export class FolkSplatViewer extends HTMLElement {
if (!container || !this._splatUrl) return;
const isGlb = this._splatUrl.endsWith(".glb") || this._splatUrl.endsWith(".gltf");
try {
// Dynamic import from CDN (via importmap)
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
const viewer = new GaussianSplats3D.Viewer({
cameraUp: [0, 1, 0],
initialCameraPosition: [5, 3, 5],
initialCameraLookAt: [0, 0, 0],
rootElement: container,
sharedMemoryForWorkers: false,
});
this._viewer = viewer;
viewer.addSplatScene(this._splatUrl, {
showLoadingUI: false,
progressiveLoad: true,
})
.then(() => {
viewer.start();
if (loading) loading.classList.add("hidden");
})
.catch((e: Error) => {
console.error("[rSplat] Scene load error:", e);
if (loading) {
const text = loading.querySelector(".splat-loading__text");
if (text) text.textContent = `Error: ${e.message}`;
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
if (spinner) spinner.style.display = "none";
}
});
if (isGlb) {
await this.initGlbViewer(container, loading);
} else {
await this.initSplatViewer(container, loading);
}
} catch (e) {
console.error("[rSplat] Viewer init error:", e);
if (loading) {
@ -664,6 +641,135 @@ export class FolkSplatViewer extends HTMLElement {
}
}
}
private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) {
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
const viewer = new GaussianSplats3D.Viewer({
cameraUp: [0, 1, 0],
initialCameraPosition: [5, 3, 5],
initialCameraLookAt: [0, 0, 0],
rootElement: container,
sharedMemoryForWorkers: false,
});
this._viewer = viewer;
viewer.addSplatScene(this._splatUrl!, {
showLoadingUI: false,
progressiveLoad: true,
})
.then(() => {
viewer.start();
if (loading) loading.classList.add("hidden");
})
.catch((e: Error) => {
console.error("[rSplat] Scene load error:", e);
if (loading) {
const text = loading.querySelector(".splat-loading__text");
if (text) text.textContent = `Error: ${e.message}`;
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
if (spinner) spinner.style.display = "none";
}
});
}
private async initGlbViewer(container: HTMLElement, loading: HTMLElement | null) {
const THREE = await import("three");
const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js");
const { GLTFLoader } = await import("three/examples/jsm/loaders/GLTFLoader.js");
const w = container.clientWidth || 800;
const h = container.clientHeight || 600;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f0f14);
const camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 1000);
camera.position.set(3, 2, 3);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(w, h);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
// Lighting
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 10, 7);
scene.add(dirLight);
const fillLight = new THREE.DirectionalLight(0x8888ff, 0.4);
fillLight.position.set(-5, 3, -5);
scene.add(fillLight);
// Load GLB
const loader = new GLTFLoader();
loader.load(
this._splatUrl!,
(gltf: any) => {
const model = gltf.scene;
// Auto-center and scale
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 2 / maxDim;
model.scale.setScalar(scale);
model.position.sub(center.multiplyScalar(scale));
scene.add(model);
controls.target.set(0, 0, 0);
controls.update();
if (loading) loading.classList.add("hidden");
},
undefined,
(err: any) => {
console.error("[rSplat] GLB load error:", err);
if (loading) {
const text = loading.querySelector(".splat-loading__text");
if (text) text.textContent = `Error loading GLB: ${err.message || err}`;
const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
if (spinner) spinner.style.display = "none";
}
}
);
// Animation loop
let animId: number;
const animate = () => {
animId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
// Handle resize
const onResize = () => {
const rw = container.clientWidth || 800;
const rh = container.clientHeight || 600;
camera.aspect = rw / rh;
camera.updateProjectionMatrix();
renderer.setSize(rw, rh);
};
window.addEventListener("resize", onResize);
// Store cleanup reference
(this as any)._glbCleanup = () => {
cancelAnimationFrame(animId);
window.removeEventListener("resize", onResize);
controls.dispose();
renderer.dispose();
};
}
}
// ── Helpers ──

View File

@ -726,6 +726,7 @@ export const splatModule: RSpaceModule = {
name: "rSplat",
icon: "🔮",
description: "3D Gaussian splat viewer",
publicWrite: true,
scoping: { defaultScope: 'global', userConfigurable: true },
docSchemas: [{ pattern: '{space}:splat:scenes', description: 'Splat scene metadata', init: splatScenesSchema.init }],
routes,

View File

@ -1112,6 +1112,7 @@ app.get('/api/account/status', async (c) => {
return c.json({
email: hasEmail,
emailAddress: profile?.profileEmail || null,
multiDevice: hasMultiDevice,
socialRecovery: hasRecovery,
credentialCount: creds.length,
@ -3385,6 +3386,15 @@ app.delete('/api/spaces/:slug/members/:did', async (c) => {
return c.json({ success: true });
});
// ── Internal: email lookup by userId (no auth, internal network only) ──
app.get('/api/internal/user-email/:userId', async (c) => {
const userId = c.req.param('userId');
if (!userId) return c.json({ error: 'userId required' }, 400);
const profile = await getUserProfile(userId);
if (!profile) return c.json({ error: 'User not found' }, 404);
return c.json({ email: profile.profileEmail || null, username: profile.username || null });
});
// ============================================================================
// USER LOOKUP
// ============================================================================