feat(broker): branded react-email template for mesh invite
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:
@@ -17,6 +17,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.71.2",
|
"@anthropic-ai/sdk": "0.71.2",
|
||||||
"@qdrant/js-client-rest": "1.17.0",
|
"@qdrant/js-client-rest": "1.17.0",
|
||||||
|
"@react-email/components": "0.3.2",
|
||||||
|
"@react-email/render": "1.3.2",
|
||||||
"@turbostarter/db": "workspace:*",
|
"@turbostarter/db": "workspace:*",
|
||||||
"@turbostarter/shared": "workspace:*",
|
"@turbostarter/shared": "workspace:*",
|
||||||
"drizzle-orm": "0.44.7",
|
"drizzle-orm": "0.44.7",
|
||||||
@@ -24,6 +26,8 @@
|
|||||||
"libsodium-wrappers": "0.7.15",
|
"libsodium-wrappers": "0.7.15",
|
||||||
"minio": "8.0.7",
|
"minio": "8.0.7",
|
||||||
"neo4j-driver": "6.0.1",
|
"neo4j-driver": "6.0.1",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
"ws": "8.20.0",
|
"ws": "8.20.0",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
@@ -33,6 +37,8 @@
|
|||||||
"@turbostarter/tsconfig": "workspace:*",
|
"@turbostarter/tsconfig": "workspace:*",
|
||||||
"@turbostarter/vitest-config": "workspace:*",
|
"@turbostarter/vitest-config": "workspace:*",
|
||||||
"@types/libsodium-wrappers": "0.7.14",
|
"@types/libsodium-wrappers": "0.7.14",
|
||||||
|
"@types/react": "19.2.0",
|
||||||
|
"@types/react-dom": "19.2.0",
|
||||||
"@types/ws": "8.5.13",
|
"@types/ws": "8.5.13",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
|
|||||||
109
apps/broker/src/emails/mesh-invitation.tsx
Normal file
109
apps/broker/src/emails/mesh-invitation.tsx
Normal 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;
|
||||||
@@ -5139,19 +5139,24 @@ async function handleCliMeshInvite(req: IncomingMessage, slug: string, res: Serv
|
|||||||
const fromAddr = process.env.EMAIL_FROM ?? "noreply@claudemesh.com";
|
const fromAddr = process.env.EMAIL_FROM ?? "noreply@claudemesh.com";
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
try {
|
try {
|
||||||
const subject = `You've been invited to the "${m.name}" mesh on claudemesh`;
|
const { render } = await import("@react-email/render");
|
||||||
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 { 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
|
const res = process.env.POSTMARK_API_KEY
|
||||||
? await fetch("https://api.postmarkapp.com/email", {
|
? await fetch("https://api.postmarkapp.com/email", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", "X-Postmark-Server-Token": apiKey },
|
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),
|
signal: AbortSignal.timeout(10_000),
|
||||||
})
|
})
|
||||||
: await fetch("https://api.resend.com/emails", {
|
: await fetch("https://api.resend.com/emails", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
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),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
emailed = res.ok;
|
emailed = res.ok;
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"]
|
"~/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"],
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
3433
pnpm-lock.yaml
generated
3433
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user