feat(invite): branded email + one-command install+launch UX
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Email (broker):
- Rebrand mesh-invitation.tsx to match site (clay accent #d97757,
  cream fg, Anthropic Serif/Mono, dark bg). Mesh glyph in header.
- Hero CTA links to the /i/short URL landing page.
- Single one-liner 'npm i -g claudemesh-cli && claudemesh launch --join URL'
  so new users copy once, paste once, done.

Web InstallToggle:
- Replace two-step numbered list with single one-liner in the first-time
  panel. Reduces copy/paste ops from 2 to 1 and stops prescribing
  'YourName' as a literal (CLI now defaults to $USER).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 02:14:27 +01:00
parent 77ee1d0d80
commit ce52fcef2d
3 changed files with 299 additions and 119 deletions

View File

@@ -9,7 +9,6 @@ import {
Link, Link,
Preview, Preview,
Section, Section,
Tailwind,
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import * as React from "react"; import * as React from "react";
@@ -17,84 +16,295 @@ import * as React from "react";
interface MeshInvitationProps { interface MeshInvitationProps {
meshName: string; meshName: string;
inviteUrl: string; inviteUrl: string;
token: string;
expiresAt: string; expiresAt: string;
appBaseUrl: string; appBaseUrl: string;
} }
// Brand tokens — mirror of apps/web/src/assets/styles/globals.css (--cm-*).
// Inlined here because email clients don't resolve CSS vars.
const brand = {
bg: "#141413",
bgElevated: "#1f1e1d",
bgCode: "#0f0e0d",
fg: "#faf9f5",
fgSecondary: "#c2c0b6",
fgTertiary: "#87867f",
clay: "#d97757",
clayBorder: "rgba(217, 119, 87, 0.35)",
border: "rgba(217, 119, 87, 0.2)",
serif: 'Georgia, "Times New Roman", serif',
mono: '"JetBrains Mono", "SF Mono", Menlo, Consolas, monospace',
sans:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
} as const;
export const MeshInvitation = ({ export const MeshInvitation = ({
meshName, meshName,
inviteUrl, inviteUrl,
token,
expiresAt, expiresAt,
appBaseUrl, appBaseUrl,
}: MeshInvitationProps) => { }: MeshInvitationProps) => {
const expiresLabel = new Date(expiresAt).toUTCString(); const expiresLabel = new Date(expiresAt).toUTCString();
const launchCmd = `claudemesh launch --join ${inviteUrl}`;
const oneLiner = `npm i -g claudemesh-cli && ${launchCmd}`;
return ( return (
<Html lang="en"> <Html lang="en">
<Head /> <Head>
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
</Head>
<Preview>You've been invited to the {meshName} mesh on claudemesh</Preview> <Preview>You've been invited to the {meshName} mesh on claudemesh</Preview>
<Tailwind> <Body
<Body className="bg-slate-50 font-sans py-10"> style={{
<Container className="bg-white rounded-xl border border-solid border-slate-200 mx-auto max-w-[520px] p-10"> backgroundColor: brand.bg,
<Section className="mb-8"> color: brand.fg,
<Text className="font-mono text-sm font-semibold text-slate-900 tracking-tight m-0"> fontFamily: brand.sans,
◇ claudemesh margin: 0,
</Text> padding: "40px 0",
</Section> }}
>
<Container
style={{
maxWidth: "560px",
margin: "0 auto",
padding: "0 24px",
}}
>
{/* Header — mesh glyph + wordmark */}
<Section style={{ marginBottom: "40px" }}>
<table role="presentation" cellPadding={0} cellSpacing={0} border={0}>
<tr>
<td style={{ verticalAlign: "middle", paddingRight: "10px" }}>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="4" r="2" fill={brand.clay} />
<circle cx="4" cy="12" r="2" fill={brand.clay} />
<circle cx="20" cy="12" r="2" fill={brand.clay} />
<circle cx="12" cy="20" r="2" fill={brand.clay} />
<path
d="M12 4L4 12M12 4L20 12M4 12L12 20M20 12L12 20M4 12L20 12M12 4L12 20"
stroke={brand.clay}
strokeWidth="1.2"
opacity="0.45"
fill="none"
/>
</svg>
</td>
<td style={{ verticalAlign: "middle" }}>
<Text
style={{
fontFamily: brand.serif,
fontSize: "17px",
fontWeight: 500,
letterSpacing: "-0.01em",
color: brand.fg,
margin: 0,
}}
>
claudemesh
</Text>
</td>
</tr>
</table>
</Section>
<Heading className="text-[26px] font-semibold tracking-tight text-slate-900 leading-tight mt-0 mb-4"> {/* Eyebrow */}
You're invited to join{" "} <Text
<span className="font-mono text-indigo-600">{meshName}</span> style={{
</Heading> fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.clay,
margin: "0 0 16px 0",
}}
>
— you're invited
</Text>
<Text className="text-slate-600 text-base leading-relaxed mt-0 mb-8"> {/* Heading */}
Someone invited you to join their mesh on claudemesh a peer <Heading
network for Claude Code sessions. Accept the invite to connect as="h1"
your session with theirs. style={{
fontFamily: brand.serif,
fontSize: "32px",
fontWeight: 500,
lineHeight: "1.15",
letterSpacing: "-0.01em",
color: brand.fg,
margin: "0 0 20px 0",
}}
>
Join{" "}
<span style={{ fontFamily: brand.mono, color: brand.clay }}>
{meshName}
</span>{" "}
on claudemesh
</Heading>
{/* Body prose */}
<Text
style={{
fontFamily: brand.serif,
fontSize: "16px",
lineHeight: "1.65",
color: brand.fgSecondary,
margin: "0 0 32px 0",
}}
>
claudemesh is a peer mesh for Claude Code sessions end-to-end
encrypted, keys stay on your machine. Open the link below to see
the mesh, the inviter, and the command to join.
</Text>
{/* Primary CTA */}
<Section style={{ marginBottom: "36px" }}>
<Button
href={inviteUrl}
style={{
backgroundColor: brand.clay,
color: brand.fg,
fontFamily: brand.sans,
fontSize: "15px",
fontWeight: 500,
textDecoration: "none",
padding: "14px 28px",
borderRadius: "4px",
display: "inline-block",
}}
>
Open invite
</Button>
</Section>
{/* Terminal shortcut — for the already-set-up crowd */}
<Text
style={{
fontFamily: brand.mono,
fontSize: "11px",
textTransform: "uppercase",
letterSpacing: "0.22em",
color: brand.fgTertiary,
margin: "0 0 12px 0",
}}
>
already have the CLI?
</Text>
<Section
style={{
backgroundColor: brand.bgElevated,
border: `1px solid ${brand.clayBorder}`,
borderRadius: "6px",
padding: "16px 18px",
marginBottom: "32px",
}}
>
<Text
style={{
fontFamily: brand.mono,
fontSize: "12px",
color: brand.fg,
margin: 0,
wordBreak: "break-all",
lineHeight: "1.6",
}}
>
{launchCmd}
</Text> </Text>
</Section>
<Section className="text-center mb-8"> {/* First-time one-liner */}
<Button <Text
href={inviteUrl} style={{
className="bg-slate-900 text-white rounded-lg px-6 py-3 text-sm font-medium no-underline box-border" fontFamily: brand.mono,
> fontSize: "11px",
Accept invite textTransform: "uppercase",
</Button> letterSpacing: "0.22em",
</Section> color: brand.fgTertiary,
margin: "0 0 12px 0",
<Text className="text-slate-500 text-sm leading-relaxed mt-0 mb-2"> }}
Or copy this link into your browser: >
first time? one command
</Text>
<Section
style={{
backgroundColor: brand.bgElevated,
border: `1px solid ${brand.border}`,
borderRadius: "6px",
padding: "16px 18px",
marginBottom: "32px",
}}
>
<Text
style={{
fontFamily: brand.mono,
fontSize: "12px",
color: brand.fg,
margin: 0,
lineHeight: "1.6",
wordBreak: "break-all",
}}
>
{oneLiner}
</Text> </Text>
<Text className="m-0 mb-8"> <Text
<Link style={{
href={inviteUrl} fontFamily: brand.serif,
className="text-indigo-600 text-sm font-mono break-all" fontSize: "12px",
> color: brand.fgTertiary,
{inviteUrl} margin: "8px 0 0 0",
</Link> }}
>
Requires Node.js 20+. Display name defaults to $USER.
</Text> </Text>
</Section>
<Hr className="border-slate-200 my-6" /> <Hr
style={{
border: "none",
borderTop: `1px solid ${brand.border}`,
margin: "28px 0 20px 0",
}}
/>
<Text className="text-slate-400 text-xs leading-relaxed m-0"> {/* Footer meta */}
This invite expires on{" "} <Text
<span className="text-slate-500">{expiresLabel}</span>. If you style={{
weren't expecting this email, you can safely ignore it. fontFamily: brand.serif,
</Text> fontSize: "13px",
</Container> lineHeight: "1.6",
color: brand.fgTertiary,
<Container className="max-w-[520px] mx-auto mt-6 text-center"> margin: "0 0 8px 0",
<Text className="text-slate-400 text-xs m-0"> }}
<Link >
href={appBaseUrl} Expires{" "}
className="text-slate-400 underline" <span style={{ color: brand.fgSecondary }}>{expiresLabel}</span>.
> If you weren't expecting this, you can ignore it.
claudemesh.com </Text>
</Link> <Text
</Text> style={{
</Container> fontFamily: brand.mono,
</Body> fontSize: "11px",
</Tailwind> color: brand.fgTertiary,
margin: 0,
}}
>
<Link
href={appBaseUrl}
style={{ color: brand.fgTertiary, textDecoration: "underline" }}
>
claudemesh.com
</Link>
</Text>
</Container>
</Body>
</Html> </Html>
); );
}; };
@@ -102,6 +312,7 @@ export const MeshInvitation = ({
MeshInvitation.PreviewProps = { MeshInvitation.PreviewProps = {
meshName: "prueba1", meshName: "prueba1",
inviteUrl: "https://claudemesh.com/i/RUVMYXZQ", inviteUrl: "https://claudemesh.com/i/RUVMYXZQ",
token: "eyJ2IjoxLCJtZXNoX2lkIjoiQUtMYUZxR3FKOGZCajN0U3dvVk1PSFYxQmF3UGlYTE8iLCJtZXNoX3NsdWciOiJwcnVlYmExIn0",
expiresAt: "2026-04-22T00:51:26.181Z", expiresAt: "2026-04-22T00:51:26.181Z",
appBaseUrl: "https://claudemesh.com", appBaseUrl: "https://claudemesh.com",
} satisfies MeshInvitationProps; } satisfies MeshInvitationProps;

View File

@@ -5143,7 +5143,7 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
const { MeshInvitation } = await import("./emails/mesh-invitation"); const { MeshInvitation } = await import("./emails/mesh-invitation");
const React = await import("react"); const React = await import("react");
const subject = `You're invited to join "${m.name}" on claudemesh`; const subject = `You're invited to join "${m.name}" on claudemesh`;
const element = React.createElement(MeshInvitation, { meshName: m.name, inviteUrl: url, expiresAt: expiresAt.toISOString(), appBaseUrl: baseUrl }); const element = React.createElement(MeshInvitation, { meshName: m.name, inviteUrl: url, token, expiresAt: expiresAt.toISOString(), appBaseUrl: baseUrl });
const html = await render(element); const html = await render(element);
const text = await render(element, { plainText: true }); const text = await render(element, { plainText: true });
const res = process.env.POSTMARK_API_KEY const res = process.env.POSTMARK_API_KEY

View File

@@ -5,8 +5,9 @@ interface Props {
token: string; token: string;
} }
const LAUNCH_CMD = (token: string) => `claudemesh launch --name YourName --join ${token}`; const LAUNCH_CMD = (token: string) => `claudemesh launch --join ${token}`;
const JOIN_CMD = (token: string) => `claudemesh join ${token}`; const INSTALL_AND_LAUNCH = (token: string) =>
`npm i -g claudemesh-cli && claudemesh launch --join ${token}`;
const INSTALL_CMD = "npm i -g claudemesh-cli"; const INSTALL_CMD = "npm i -g claudemesh-cli";
export const InstallToggle = ({ token }: Props) => { export const InstallToggle = ({ token }: Props) => {
@@ -97,71 +98,39 @@ export const InstallToggle = ({ token }: Props) => {
); );
} }
const launchCmd = LAUNCH_CMD(token); const oneLiner = INSTALL_AND_LAUNCH(token);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<ol className="space-y-3"> <div className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5">
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-border)] bg-[var(--cm-bg-elevated)] p-5"> <div
<div className="mb-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]"
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]" style={{ fontFamily: "var(--cm-font-mono)" }}
>
install + launch one command
</div>
<div className="flex items-center gap-2">
<code
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }} style={{ fontFamily: "var(--cm-font-mono)" }}
> >
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">1</span> {oneLiner}
install the CLI </code>
</div> <button
<div className="flex items-center gap-2"> onClick={() => copy(oneLiner, "one")}
<code className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-4 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]" style={{ fontFamily: "var(--cm-font-sans)" }}
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{INSTALL_CMD}
</code>
<button
onClick={() => copy(INSTALL_CMD, "install")}
className="rounded-[var(--cm-radius-xs)] border border-[var(--cm-border)] px-3 py-3 text-sm text-[var(--cm-fg-secondary)] transition-colors hover:border-[var(--cm-fg)] hover:text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{copiedKey === "install" ? "Copied ✓" : "Copy"}
</button>
</div>
<p
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
> >
Requires Node.js 20+. {copiedKey === "one" ? "Copied ✓" : "Copy"}
</p> </button>
</li> </div>
<li className="rounded-[var(--cm-radius-md)] border border-[var(--cm-clay)]/40 bg-[var(--cm-bg-elevated)] p-5"> <p
<div className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-[var(--cm-clay)]" style={{ fontFamily: "var(--cm-font-serif)" }}
style={{ fontFamily: "var(--cm-font-mono)" }} >
> Requires Node.js 20+. Your display name defaults to <code style={{ fontFamily: "var(--cm-font-mono)" }}>$USER</code> override with{" "}
<span className="rounded-full bg-[var(--cm-clay)]/20 px-1.5">2</span> <code style={{ fontFamily: "var(--cm-font-mono)" }}>--name YourName</code>.
join + launch </p>
</div> </div>
<div className="flex items-center gap-2">
<code
className="flex-1 overflow-x-auto rounded-[var(--cm-radius-xs)] bg-[var(--cm-bg)] p-3 text-sm text-[var(--cm-fg)]"
style={{ fontFamily: "var(--cm-font-mono)" }}
>
{launchCmd}
</code>
<button
onClick={() => copy(launchCmd, "join")}
className="rounded-[var(--cm-radius-xs)] bg-[var(--cm-clay)] px-3 py-3 text-sm font-medium text-[var(--cm-fg)] transition-colors hover:bg-[var(--cm-clay-hover)]"
style={{ fontFamily: "var(--cm-font-sans)" }}
>
{copiedKey === "join" ? "Copied ✓" : "Copy"}
</button>
</div>
<p
className="mt-2 text-xs text-[var(--cm-fg-tertiary)]"
style={{ fontFamily: "var(--cm-font-serif)" }}
>
Joins the mesh and launches Claude Code in one step.
</p>
</li>
</ol>
<button <button
onClick={() => setHasCli("unknown")} onClick={() => setHasCli("unknown")}
className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]" className="text-xs text-[var(--cm-fg-tertiary)] underline underline-offset-4 hover:text-[var(--cm-fg)]"