merge(dev): redesign space invitation email
CI/CD / deploy (push) Successful in 3m40s Details

This commit is contained in:
Jeff Emmett 2026-04-17 13:46:52 -04:00
commit 83671d3e35
3 changed files with 190 additions and 49 deletions

View File

@ -265,12 +265,25 @@ async function sendEmailNotification(stored: StoredNotification, opts: NotifyOpt
if (!transport) return;
const space = opts.spaceSlug || "rspace";
const fromAddr = `${space} agent <${space}-agent@rspace.online>`;
const agentAddr = `${space}-agent@rspace.online`;
const fromAddr = `${space} agent <${agentAddr}>`;
const actionLink = opts.actionUrl
? `https://${space}.rspace.online${opts.actionUrl}`
? (opts.actionUrl.startsWith("http")
? opts.actionUrl
: `https://${space}.rspace.online${opts.actionUrl}`)
: `https://${space}.rspace.online`;
const html = `
// Use richer template for space invites
const isSpaceInvite = stored.category === 'space' && stored.eventType === 'space_invite';
const role = (opts.metadata?.role as string) || 'member';
const inviterName = stored.actorUsername || 'an admin';
const subject = isSpaceInvite
? `${inviterName} invited you to join "${space}" on rSpace`
: stored.title;
const html = isSpaceInvite
? renderSpaceInviteEmail({ spaceSlug: space, inviterName, role, acceptUrl: actionLink })
: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<div style="background: #1e293b; border-radius: 10px; padding: 20px; color: #e2e8f0;">
<h2 style="margin: 0 0 8px; font-size: 16px; color: #f1f5f9;">${escapeHtml(stored.title)}</h2>
@ -282,23 +295,49 @@ async function sendEmailNotification(stored: StoredNotification, opts: NotifyOpt
</p>
</div>`;
const agentAddr = `${space}-agent@rspace.online`;
try {
await transport.sendMail({
from: fromAddr,
to: userEmail,
subject: stored.title,
subject,
html,
replyTo: agentAddr,
envelope: { from: agentAddr, to: userEmail },
});
await markNotificationDelivered(stored.id, 'email');
console.log(`[email] Sent "${stored.title}" to ${opts.userDid}`);
console.log(`[email] Sent "${subject}" to ${opts.userDid}`);
} catch (err: any) {
console.error(`[email] Failed to send to ${opts.userDid}:`, err.message);
}
}
function renderSpaceInviteEmail(opts: {
spaceSlug: string;
inviterName: string;
role: string;
acceptUrl: string;
}): string {
const safeSpace = escapeHtml(opts.spaceSlug);
const safeInviter = escapeHtml(opts.inviterName);
const safeRole = escapeHtml(opts.role);
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1e293b;background:#f8fafc;">
<div style="background:#ffffff;border-radius:14px;padding:32px 28px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<p style="margin:0 0 6px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.1em;font-weight:600;">Invitation &middot; rSpace</p>
<h1 style="margin:0 0 14px;font-size:24px;color:#0f172a;line-height:1.3;font-weight:700;">You're invited to join <span style="color:#0d9488;">${safeSpace}</span></h1>
<p style="margin:0 0 14px;font-size:15px;color:#334155;line-height:1.6;"><strong>${safeInviter}</strong> invited you to join the <strong>${safeSpace}</strong> space on rSpace as a <strong>${safeRole}</strong>.</p>
<p style="margin:0 0 22px;font-size:14px;color:#475569;line-height:1.6;">rSpace is a privacy-first collaborative workspace. Once you accept, you'll be able to coordinate with other members of <strong>${safeSpace}</strong> using shared digital collaboration tools &mdash; notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more.</p>
<div style="text-align:center;margin:28px 0 22px;">
<a href="${opts.acceptUrl}" style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#ffffff;text-decoration:none;border-radius:10px;font-weight:700;font-size:17px;box-shadow:0 4px 14px rgba(13,148,136,0.35);letter-spacing:0.01em;">Accept &amp; Join ${safeSpace} &rarr;</a>
</div>
<p style="margin:0 0 6px;font-size:12px;color:#64748b;text-align:center;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 22px;font-size:12px;color:#0d9488;text-align:center;word-break:break-all;">${opts.acceptUrl}</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0 16px;">
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.5;text-align:center;">This invite expires in 7 days. rSpace uses passkeys &mdash; no passwords, no seed phrases.</p>
</div>
</div>`;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

View File

@ -2142,6 +2142,44 @@ spaces.post("/:slug/copy-shapes", async (c) => {
// ── Invite by email ──
function escHtml(s: string): string {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function renderSpaceInviteEmail(opts: {
spaceSlug: string;
spaceName: string;
inviterName: string;
role: string;
acceptUrl: string;
personalMessage?: string;
}): string {
const { spaceName, inviterName, role, acceptUrl, personalMessage } = opts;
const safeSpace = escHtml(spaceName);
const safeInviter = escHtml(inviterName);
const safeRole = escHtml(role);
const messageBlock = personalMessage
? `<blockquote style="border-left:3px solid #14b8a6;padding:0.5rem 1rem;margin:16px 0;color:#475569;background:#f8fafc;border-radius:0 8px 8px 0;font-style:italic;">&ldquo;${escHtml(personalMessage)}&rdquo;</blockquote>`
: "";
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1e293b;background:#f8fafc;">
<div style="background:#ffffff;border-radius:14px;padding:32px 28px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<p style="margin:0 0 6px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.1em;font-weight:600;">Invitation &middot; rSpace</p>
<h1 style="margin:0 0 14px;font-size:24px;color:#0f172a;line-height:1.3;font-weight:700;">You're invited to join <span style="color:#0d9488;">${safeSpace}</span></h1>
<p style="margin:0 0 14px;font-size:15px;color:#334155;line-height:1.6;"><strong>${safeInviter}</strong> invited you to join the <strong>${safeSpace}</strong> space on rSpace as a <strong>${safeRole}</strong>.</p>
${messageBlock}
<p style="margin:0 0 22px;font-size:14px;color:#475569;line-height:1.6;">rSpace is a privacy-first collaborative workspace. Once you accept, you'll be able to coordinate with other members of <strong>${safeSpace}</strong> using shared digital collaboration tools &mdash; notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more.</p>
<div style="text-align:center;margin:28px 0 22px;">
<a href="${acceptUrl}" style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#ffffff;text-decoration:none;border-radius:10px;font-weight:700;font-size:17px;box-shadow:0 4px 14px rgba(13,148,136,0.35);letter-spacing:0.01em;">Accept &amp; Join ${safeSpace} &rarr;</a>
</div>
<p style="margin:0 0 6px;font-size:12px;color:#64748b;text-align:center;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 22px;font-size:12px;color:#0d9488;text-align:center;word-break:break-all;">${acceptUrl}</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0 16px;">
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.5;text-align:center;">This invite expires in 7 days. rSpace uses passkeys &mdash; no passwords, no seed phrases.</p>
</div>
</div>`;
}
let inviteTransport: Transporter | null = null;
{
@ -2200,7 +2238,12 @@ spaces.post("/:slug/invite", async (c) => {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({ email: body.email, spaceSlug: slug, spaceRole: role }),
body: JSON.stringify({
email: body.email,
spaceSlug: slug,
spaceName: data.meta.name || slug,
spaceRole: role,
}),
});
if (identityRes.status === 201 || identityRes.ok) {
@ -2256,22 +2299,20 @@ spaces.post("/:slug/invite", async (c) => {
if (inviteTransport) {
try {
const agentAddr = `${slug}-agent@rspace.online`;
const displayName = data.meta.name || slug;
await inviteTransport.sendMail({
from: `${slug} <${agentAddr}>`,
from: `${displayName} <${agentAddr}>`,
replyTo: agentAddr,
envelope: { from: agentAddr, to: body.email },
to: body.email,
subject: `${inviterName} invited you to "${slug}" on rSpace`,
html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
<p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools notes, maps, voting, calendar, and more.</p>
<p style="text-align:center;margin:2rem 0;">
<a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
</p>
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. rSpace</p>
</div>`,
subject: `${inviterName} invited you to join "${displayName}" on rSpace`,
html: renderSpaceInviteEmail({
spaceSlug: slug,
spaceName: displayName,
inviterName,
role,
acceptUrl,
}),
});
} catch (emailErr: any) {
console.error("Invite email notification failed:", emailErr.message);
@ -2386,22 +2427,20 @@ spaces.post("/:slug/members/add", async (c) => {
if (inviteTransport && targetEmail) {
try {
const agentAddr = `${slug}-agent@rspace.online`;
const displayName = data.meta.name || slug;
await inviteTransport.sendMail({
from: `${slug} <${agentAddr}>`,
from: `${displayName} <${agentAddr}>`,
replyTo: agentAddr,
envelope: { from: agentAddr, to: targetEmail },
to: targetEmail,
subject: `${inviterName} invited you to "${slug}" on rSpace`,
html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">You're invited to ${slug}</h2>
<p style="color:#475569;line-height:1.6;"><strong>${inviterName}</strong> invited you to join the <strong>${slug}</strong> space as a <strong>${role}</strong>.</p>
<p style="color:#475569;line-height:1.6;">Accept the invitation to get access to all the collaborative tools notes, maps, voting, calendar, and more.</p>
<p style="text-align:center;margin:2rem 0;">
<a href="${acceptUrl}" style="display:inline-block;padding:12px 28px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:white;border-radius:8px;text-decoration:none;font-weight:600;font-size:1rem;">Accept Invitation</a>
</p>
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. rSpace</p>
</div>`,
subject: `${inviterName} invited you to join "${displayName}" on rSpace`,
html: renderSpaceInviteEmail({
spaceSlug: slug,
spaceName: displayName,
inviterName,
role,
acceptUrl,
}),
});
} catch (emailErr: any) {
console.error("Invite email notification failed:", emailErr.message);

View File

@ -5944,7 +5944,7 @@ app.post('/api/invites/identity', async (c) => {
}
const body = await c.req.json();
const { email, message, spaceSlug, spaceRole } = body;
const { email, message, spaceSlug, spaceName, spaceRole } = body;
if (!email || typeof email !== 'string' || !email.includes('@')) {
return c.json({ error: 'Valid email is required' }, 400);
@ -5975,31 +5975,34 @@ app.post('/api/invites/identity', async (c) => {
const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`;
if (smtpTransport) {
try {
const spaceInfo = spaceSlug
? `<p style="color:#475569;line-height:1.6;">You'll automatically join the <strong>${escapeHtml(spaceSlug)}</strong> space as a <strong>${escapeHtml(spaceRole || 'member')}</strong>, with access to collaborative notes, maps, voting, calendar, and more.</p>`
: `<p style="color:#475569;line-height:1.6;">rSpace is a suite of privacy-first collaborative tools — notes, maps, voting, calendar, wallet, and more — powered by passkey authentication (no passwords).</p>`;
const displayName = (spaceName && String(spaceName).trim()) || spaceSlug || '';
const role = spaceRole || 'member';
const subjectLine = spaceSlug
? `${payload.username} invited you to join "${spaceSlug}" on rSpace`
? `${payload.username} invited you to join "${displayName}" on rSpace`
: `${payload.username} invited you to join rSpace`;
const agentAddr = spaceSlug ? `${spaceSlug}-agent@rspace.online` : null;
const html = spaceSlug
? renderEncryptIDSpaceInviteEmail({
spaceName: displayName,
inviterName: payload.username,
role,
joinLink,
personalMessage: message,
})
: renderEncryptIDIdentityInviteEmail({
inviterName: payload.username,
joinLink,
personalMessage: message,
});
await smtpTransport.sendMail({
from: agentAddr ? `${spaceSlug} <${agentAddr}>` : CONFIG.smtp.from,
from: agentAddr ? `${displayName} <${agentAddr}>` : CONFIG.smtp.from,
...(agentAddr ? { replyTo: agentAddr } : {}),
...(agentAddr ? { envelope: { from: agentAddr, to: email } } : {}),
to: email,
subject: subjectLine,
html: `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:520px;margin:0 auto;padding:2rem;">
<h2 style="color:#1a1a2e;margin-bottom:0.5rem;">${spaceSlug ? `You've been invited to ${escapeHtml(spaceSlug)}` : `You've been invited to rSpace`}</h2>
<p style="color:#475569;line-height:1.6;"><strong>${escapeHtml(payload.username)}</strong> wants you to join${spaceSlug ? ` the <strong>${escapeHtml(spaceSlug)}</strong> space on` : ''} rSpace.</p>
${message ? `<blockquote style="border-left:3px solid #7c3aed;padding:0.5rem 1rem;margin:1rem 0;color:#475569;background:#f8fafc;border-radius:0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
${spaceInfo}
<p style="color:#475569;line-height:1.6;">Click below to create your account and set up your passkey:</p>
<p style="text-align:center;margin:2rem 0;">
<a href="${joinLink}" style="display:inline-block;padding:0.85rem 2rem;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#fff;text-decoration:none;border-radius:0.5rem;font-weight:600;font-size:1rem;">Accept Invitation</a>
</p>
<p style="color:#94a3b8;font-size:0.85rem;">This invite expires in 7 days. No passwords needed you'll use a passkey to sign in securely.</p>
</div>`,
html,
});
} catch (err) {
console.error('EncryptID: Failed to send invite email:', (err as Error).message);
@ -7638,6 +7641,66 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderEncryptIDSpaceInviteEmail(opts: {
spaceName: string;
inviterName: string;
role: string;
joinLink: string;
personalMessage?: string;
}): string {
const safeSpace = escapeHtml(opts.spaceName);
const safeInviter = escapeHtml(opts.inviterName);
const safeRole = escapeHtml(opts.role);
const messageBlock = opts.personalMessage
? `<blockquote style="border-left:3px solid #14b8a6;padding:0.5rem 1rem;margin:16px 0;color:#475569;background:#f8fafc;border-radius:0 8px 8px 0;font-style:italic;">&ldquo;${escapeHtml(opts.personalMessage)}&rdquo;</blockquote>`
: '';
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1e293b;background:#f8fafc;">
<div style="background:#ffffff;border-radius:14px;padding:32px 28px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<p style="margin:0 0 6px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.1em;font-weight:600;">Invitation &middot; rSpace</p>
<h1 style="margin:0 0 14px;font-size:24px;color:#0f172a;line-height:1.3;font-weight:700;">You're invited to join <span style="color:#0d9488;">${safeSpace}</span></h1>
<p style="margin:0 0 14px;font-size:15px;color:#334155;line-height:1.6;"><strong>${safeInviter}</strong> invited you to join the <strong>${safeSpace}</strong> space on rSpace as a <strong>${safeRole}</strong>.</p>
${messageBlock}
<p style="margin:0 0 22px;font-size:14px;color:#475569;line-height:1.6;">rSpace is a privacy-first collaborative workspace. Once you accept, you'll be able to coordinate with other members of <strong>${safeSpace}</strong> using shared digital collaboration tools &mdash; notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more.</p>
<div style="text-align:center;margin:28px 0 22px;">
<a href="${opts.joinLink}" style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#ffffff;text-decoration:none;border-radius:10px;font-weight:700;font-size:17px;box-shadow:0 4px 14px rgba(13,148,136,0.35);letter-spacing:0.01em;">Accept &amp; Join ${safeSpace} &rarr;</a>
</div>
<p style="margin:0 0 6px;font-size:12px;color:#64748b;text-align:center;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 22px;font-size:12px;color:#0d9488;text-align:center;word-break:break-all;">${opts.joinLink}</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0 16px;">
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.5;text-align:center;">You'll create your account with a passkey &mdash; no passwords, no seed phrases. This invite expires in 7 days.</p>
</div>
</div>`;
}
function renderEncryptIDIdentityInviteEmail(opts: {
inviterName: string;
joinLink: string;
personalMessage?: string;
}): string {
const safeInviter = escapeHtml(opts.inviterName);
const messageBlock = opts.personalMessage
? `<blockquote style="border-left:3px solid #14b8a6;padding:0.5rem 1rem;margin:16px 0;color:#475569;background:#f8fafc;border-radius:0 8px 8px 0;font-style:italic;">&ldquo;${escapeHtml(opts.personalMessage)}&rdquo;</blockquote>`
: '';
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1e293b;background:#f8fafc;">
<div style="background:#ffffff;border-radius:14px;padding:32px 28px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<p style="margin:0 0 6px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.1em;font-weight:600;">Invitation &middot; rSpace</p>
<h1 style="margin:0 0 14px;font-size:24px;color:#0f172a;line-height:1.3;font-weight:700;">You've been invited to <span style="color:#0d9488;">rSpace</span></h1>
<p style="margin:0 0 14px;font-size:15px;color:#334155;line-height:1.6;"><strong>${safeInviter}</strong> invited you to join rSpace.</p>
${messageBlock}
<p style="margin:0 0 22px;font-size:14px;color:#475569;line-height:1.6;">rSpace is a privacy-first collaborative workspace for groups &mdash; notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more. All in one place, with no corporate middlemen.</p>
<div style="text-align:center;margin:28px 0 22px;">
<a href="${opts.joinLink}" style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#ffffff;text-decoration:none;border-radius:10px;font-weight:700;font-size:17px;box-shadow:0 4px 14px rgba(13,148,136,0.35);letter-spacing:0.01em;">Accept Invitation &rarr;</a>
</div>
<p style="margin:0 0 6px;font-size:12px;color:#64748b;text-align:center;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 22px;font-size:12px;color:#0d9488;text-align:center;word-break:break-all;">${opts.joinLink}</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0 16px;">
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.5;text-align:center;">You'll create your account with a passkey &mdash; no passwords, no seed phrases. This invite expires in 7 days.</p>
</div>
</div>`;
}
// ============================================================================
// SERVE STATIC FILES
// ============================================================================