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
// ============================================================================