feat(broker): branded react-email template for mesh invite
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

Replaces the plain-text invite email with a standalone react-email
template (apps/broker/src/emails/mesh-invitation.tsx) using
@react-email/components + Tailwind. Rendered on demand in
handleCliMeshInvite and sent as both HtmlBody and TextBody via
Postmark (or html+text via Resend).

Self-contained — no dependency on @turbostarter/email, i18n, or ui
packages. Adds react, react-dom, @react-email/components, @react-email/render
to broker deps. Enables tsconfig jsx: react-jsx and .tsx includes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-15 02:04:28 +01:00
parent 2f27a5eef4
commit 77ee1d0d80
5 changed files with 2331 additions and 1235 deletions

View File

@@ -17,6 +17,8 @@
"dependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@qdrant/js-client-rest": "1.17.0",
"@react-email/components": "0.3.2",
"@react-email/render": "1.3.2",
"@turbostarter/db": "workspace:*",
"@turbostarter/shared": "workspace:*",
"drizzle-orm": "0.44.7",
@@ -24,6 +26,8 @@
"libsodium-wrappers": "0.7.15",
"minio": "8.0.7",
"neo4j-driver": "6.0.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"ws": "8.20.0",
"zod": "catalog:"
},
@@ -33,6 +37,8 @@
"@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14",
"@types/react": "19.2.0",
"@types/react-dom": "19.2.0",
"@types/ws": "8.5.13",
"eslint": "catalog:",
"prettier": "catalog:",

View File

@@ -0,0 +1,109 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import * as React from "react";
interface MeshInvitationProps {
meshName: string;
inviteUrl: string;
expiresAt: string;
appBaseUrl: string;
}
export const MeshInvitation = ({
meshName,
inviteUrl,
expiresAt,
appBaseUrl,
}: MeshInvitationProps) => {
const expiresLabel = new Date(expiresAt).toUTCString();
return (
<Html lang="en">
<Head />
<Preview>You've been invited to the {meshName} mesh on claudemesh</Preview>
<Tailwind>
<Body className="bg-slate-50 font-sans py-10">
<Container className="bg-white rounded-xl border border-solid border-slate-200 mx-auto max-w-[520px] p-10">
<Section className="mb-8">
<Text className="font-mono text-sm font-semibold text-slate-900 tracking-tight m-0">
◇ claudemesh
</Text>
</Section>
<Heading className="text-[26px] font-semibold tracking-tight text-slate-900 leading-tight mt-0 mb-4">
You're invited to join{" "}
<span className="font-mono text-indigo-600">{meshName}</span>
</Heading>
<Text className="text-slate-600 text-base leading-relaxed mt-0 mb-8">
Someone invited you to join their mesh on claudemesh a peer
network for Claude Code sessions. Accept the invite to connect
your session with theirs.
</Text>
<Section className="text-center mb-8">
<Button
href={inviteUrl}
className="bg-slate-900 text-white rounded-lg px-6 py-3 text-sm font-medium no-underline box-border"
>
Accept invite
</Button>
</Section>
<Text className="text-slate-500 text-sm leading-relaxed mt-0 mb-2">
Or copy this link into your browser:
</Text>
<Text className="m-0 mb-8">
<Link
href={inviteUrl}
className="text-indigo-600 text-sm font-mono break-all"
>
{inviteUrl}
</Link>
</Text>
<Hr className="border-slate-200 my-6" />
<Text className="text-slate-400 text-xs leading-relaxed m-0">
This invite expires on{" "}
<span className="text-slate-500">{expiresLabel}</span>. If you
weren't expecting this email, you can safely ignore it.
</Text>
</Container>
<Container className="max-w-[520px] mx-auto mt-6 text-center">
<Text className="text-slate-400 text-xs m-0">
<Link
href={appBaseUrl}
className="text-slate-400 underline"
>
claudemesh.com
</Link>
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
MeshInvitation.PreviewProps = {
meshName: "prueba1",
inviteUrl: "https://claudemesh.com/i/RUVMYXZQ",
expiresAt: "2026-04-22T00:51:26.181Z",
appBaseUrl: "https://claudemesh.com",
} satisfies MeshInvitationProps;
export default MeshInvitation;

View File

@@ -5139,19 +5139,24 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
const fromAddr = process.env.EMAIL_FROM ?? "noreply@claudemesh.com";
if (apiKey) {
try {
const subject = `You've been invited to the "${m.name}" mesh on claudemesh`;
const text = `You've been invited to join the "${m.name}" mesh on claudemesh.\n\nAccept the invite:\n${url}\n\nThis link expires on ${expiresAt.toISOString()}.\n\nIf you didn't expect this, ignore this email.`;
const { render } = await import("@react-email/render");
const { MeshInvitation } = await import("./emails/mesh-invitation");
const React = await import("react");
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 html = await render(element);
const text = await render(element, { plainText: true });
const res = process.env.POSTMARK_API_KEY
? await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Postmark-Server-Token": apiKey },
body: JSON.stringify({ From: fromAddr, To: body.email, Subject: subject, TextBody: text }),
body: JSON.stringify({ From: fromAddr, To: body.email, Subject: subject, HtmlBody: html, TextBody: text, MessageStream: "outbound" }),
signal: AbortSignal.timeout(10_000),
})
: await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ from: fromAddr, to: body.email, subject, text }),
body: JSON.stringify({ from: fromAddr, to: body.email, subject, html, text }),
signal: AbortSignal.timeout(10_000),
});
emailed = res.ok;

View File

@@ -8,8 +8,9 @@
"paths": {
"~/*": ["./src/*"]
},
"types": ["bun-types"]
"types": ["bun-types"],
"jsx": "react-jsx"
},
"include": ["src/**/*.ts"],
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

3433
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff