merge(dev): redesign space invitation email
CI/CD / deploy (push) Successful in 3m40s
Details
CI/CD / deploy (push) Successful in 3m40s
Details
This commit is contained in:
commit
83671d3e35
|
|
@ -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 · 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 — 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 & Join ${safeSpace} →</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 — no passwords, no seed phrases.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ">").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
|
||||
? `<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;">“${escHtml(personalMessage)}”</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 · 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 — 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 & Join ${safeSpace} →</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 — 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);
|
||||
|
|
|
|||
|
|
@ -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, '&').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
|
||||
? `<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;">“${escapeHtml(opts.personalMessage)}”</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 · 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 — 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 & Join ${safeSpace} →</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 — 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;">“${escapeHtml(opts.personalMessage)}”</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 · 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 — 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 →</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 — no passwords, no seed phrases. This invite expires in 7 days.</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SERVE STATIC FILES
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue