From 08326ea834a9685d0efb6bb5f329c6b7d08c3754 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 13:46:34 -0400 Subject: [PATCH] feat(invites): redesign space invitation email with prominent CTA Unify space invite emails across three code paths (new-user identity invite, existing-user invite, add-by-username, plus notification-service fallback). Template mentions the space name + rSpace, lists shared collaboration tools, and shows a large gradient CTA button. Pass spaceName through from server/spaces.ts so the display uses the friendly name (e.g. "Crypto Commons") instead of the slug. Also fix a bug in notification-service where actionUrl starting with http would be double-prefixed with https://{slug}.rspace.online. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/notification-service.ts | 51 +++++++++++++++--- server/spaces.ts | 89 +++++++++++++++++++++--------- src/encryptid/server.ts | 99 +++++++++++++++++++++++++++------- 3 files changed, 190 insertions(+), 49 deletions(-) diff --git a/server/notification-service.ts b/server/notification-service.ts index 5d043b7e..455f377a 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -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 }) + : `

${escapeHtml(stored.title)}

@@ -282,23 +295,49 @@ async function sendEmailNotification(stored: StoredNotification, opts: NotifyOpt

`; - 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 ` +
+
+

Invitation · rSpace

+

You're invited to join ${safeSpace}

+

${safeInviter} invited you to join the ${safeSpace} space on rSpace as a ${safeRole}.

+

rSpace is a privacy-first collaborative workspace. Once you accept, you'll be able to coordinate with other members of ${safeSpace} using shared digital collaboration tools — notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more.

+ +

Or copy and paste this link into your browser:

+

${opts.acceptUrl}

+
+

This invite expires in 7 days. rSpace uses passkeys — no passwords, no seed phrases.

+
+
`; +} + function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } diff --git a/server/spaces.ts b/server/spaces.ts index aafb66a3..d4b65e11 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -2142,6 +2142,44 @@ spaces.post("/:slug/copy-shapes", async (c) => { // ── Invite by email ── +function escHtml(s: string): string { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +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 + ? `
“${escHtml(personalMessage)}”
` + : ""; + return ` +
+
+

Invitation · rSpace

+

You're invited to join ${safeSpace}

+

${safeInviter} invited you to join the ${safeSpace} space on rSpace as a ${safeRole}.

+ ${messageBlock} +

rSpace is a privacy-first collaborative workspace. Once you accept, you'll be able to coordinate with other members of ${safeSpace} using shared digital collaboration tools — notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more.

+ +

Or copy and paste this link into your browser:

+

${acceptUrl}

+
+

This invite expires in 7 days. rSpace uses passkeys — no passwords, no seed phrases.

+
+
`; +} + 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: ` -
-

You're invited to ${slug}

-

${inviterName} invited you to join the ${slug} space as a ${role}.

-

Accept the invitation to get access to all the collaborative tools — notes, maps, voting, calendar, and more.

-

- Accept Invitation -

-

This invite expires in 7 days. — rSpace

-
`, + 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: ` -
-

You're invited to ${slug}

-

${inviterName} invited you to join the ${slug} space as a ${role}.

-

Accept the invitation to get access to all the collaborative tools — notes, maps, voting, calendar, and more.

-

- Accept Invitation -

-

This invite expires in 7 days. — rSpace

-
`, + 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); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 20c65538..153f1c0f 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -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 - ? `

You'll automatically join the ${escapeHtml(spaceSlug)} space as a ${escapeHtml(spaceRole || 'member')}, with access to collaborative notes, maps, voting, calendar, and more.

` - : `

rSpace is a suite of privacy-first collaborative tools — notes, maps, voting, calendar, wallet, and more — powered by passkey authentication (no passwords).

`; + 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: ` -
-

${spaceSlug ? `You've been invited to ${escapeHtml(spaceSlug)}` : `You've been invited to rSpace`}

-

${escapeHtml(payload.username)} wants you to join${spaceSlug ? ` the ${escapeHtml(spaceSlug)} space on` : ''} rSpace.

- ${message ? `
"${escapeHtml(message)}"
` : ''} - ${spaceInfo} -

Click below to create your account and set up your passkey:

-

- Accept Invitation -

-

This invite expires in 7 days. No passwords needed — you'll use a passkey to sign in securely.

-
`, + 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, '&').replace(//g, '>').replace(/"/g, '"'); } +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 + ? `
“${escapeHtml(opts.personalMessage)}”
` + : ''; + return ` +
+
+

Invitation · rSpace

+

You're invited to join ${safeSpace}

+

${safeInviter} invited you to join the ${safeSpace} space on rSpace as a ${safeRole}.

+ ${messageBlock} +

rSpace is a privacy-first collaborative workspace. Once you accept, you'll be able to coordinate with other members of ${safeSpace} using shared digital collaboration tools — notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more.

+ +

Or copy and paste this link into your browser:

+

${opts.joinLink}

+
+

You'll create your account with a passkey — no passwords, no seed phrases. This invite expires in 7 days.

+
+
`; +} + +function renderEncryptIDIdentityInviteEmail(opts: { + inviterName: string; + joinLink: string; + personalMessage?: string; +}): string { + const safeInviter = escapeHtml(opts.inviterName); + const messageBlock = opts.personalMessage + ? `
“${escapeHtml(opts.personalMessage)}”
` + : ''; + return ` +
+
+

Invitation · rSpace

+

You've been invited to rSpace

+

${safeInviter} invited you to join rSpace.

+ ${messageBlock} +

rSpace is a privacy-first collaborative workspace for groups — notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more. All in one place, with no corporate middlemen.

+ +

Or copy and paste this link into your browser:

+

${opts.joinLink}

+
+

You'll create your account with a passkey — no passwords, no seed phrases. This invite expires in 7 days.

+
+
`; +} + // ============================================================================ // SERVE STATIC FILES // ============================================================================