feat: implement beautiful jsx-email templates for OTP and team invites (#196)

Co-authored-by: opencode <noreply@opencode.ai>
This commit is contained in:
KM Koushik
2025-08-17 13:17:29 +10:00
committed by GitHub
parent 43d99bb980
commit 91286876da
12 changed files with 571 additions and 54 deletions

View File

@@ -0,0 +1,99 @@
import React from "react";
import { Container, Text } from "jsx-email";
import { render } from "jsx-email";
import { EmailLayout } from "./components/EmailLayout";
import { EmailHeader } from "./components/EmailHeader";
import { EmailFooter } from "./components/EmailFooter";
import { EmailButton } from "./components/EmailButton";
interface OtpEmailProps {
otpCode: string;
loginUrl: string;
hostName?: string;
logoUrl?: string;
}
export function OtpEmail({
otpCode,
loginUrl,
hostName = "Unsend",
logoUrl,
}: OtpEmailProps) {
return (
<EmailLayout preview={`Your verification code: ${otpCode}`}>
<EmailHeader logoUrl={logoUrl} title="Sign in to your account" />
<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 24px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Hi there,
</Text>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 32px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Use the verification code below to sign in to your Unsend account:
</Text>
<Container
style={{
backgroundColor: "#f8f9fa",
padding: "16px",
margin: "0 0 32px 0",
textAlign: "left" as const,
}}
>
<Text
style={{
fontSize: "24px",
fontWeight: "700",
color: "#000000",
letterSpacing: "4px",
margin: "0",
fontFamily: "monospace",
textAlign: "left" as const,
}}
>
{otpCode}
</Text>
</Container>
<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={loginUrl}>Sign in with one click</EmailButton>
</Container>
<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: "0",
lineHeight: "1.5",
textAlign: "left" as const,
}}
>
If you didn't request this email, you can safely ignore it. The
verification code will expire automatically.
</Text>
</Container>
<EmailFooter />
</EmailLayout>
);
}
export async function renderOtpEmail(props: OtpEmailProps): Promise<string> {
return render(<OtpEmail {...props} />);
}

View File

@@ -0,0 +1,90 @@
import React from "react";
import { Container, Text } from "jsx-email";
import { render } from "jsx-email";
import { EmailLayout } from "./components/EmailLayout";
import { EmailHeader } from "./components/EmailHeader";
import { EmailFooter } from "./components/EmailFooter";
import { EmailButton } from "./components/EmailButton";
interface TeamInviteEmailProps {
teamName: string;
inviteUrl: string;
inviterName?: string;
logoUrl?: string;
role?: string;
}
export function TeamInviteEmail({
teamName,
inviteUrl,
inviterName,
logoUrl,
role = "member",
}: TeamInviteEmailProps) {
return (
<EmailLayout preview={`You've been invited to join ${teamName} on Unsend`}>
<EmailHeader logoUrl={logoUrl} title="You're invited!" />
<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 24px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Hi there,
</Text>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 32px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
{inviterName
? `${inviterName} has invited you to join `
: "You have been invited to join "}
<strong style={{ color: "#000000" }}>{teamName}</strong> on Unsend
{role && role !== "member" && (
<span>
{" "}
as a <strong style={{ color: "#000000" }}>{role}</strong>
</span>
)}
.
</Text>
<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={inviteUrl}>Accept invitation</EmailButton>
</Container>
<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: "0",
lineHeight: "1.5",
textAlign: "left" as const,
}}
>
If you weren't expecting this invitation or don't want to join this
team, you can safely ignore this email.
</Text>
</Container>
<EmailFooter />
</EmailLayout>
);
}
export async function renderTeamInviteEmail(
props: TeamInviteEmailProps
): Promise<string> {
return render(<TeamInviteEmail {...props} />);
}

View File

@@ -0,0 +1,38 @@
import React from "react";
import { Link } from "jsx-email";
interface EmailButtonProps {
href: string;
children: React.ReactNode;
variant?: "primary" | "secondary";
}
export function EmailButton({
href,
children,
variant = "primary"
}: EmailButtonProps) {
return (
<a
href={href}
style={{
backgroundColor: "#000000",
color: "#ffffff",
border: "1px solid #000000",
borderRadius: "4px",
fontSize: "16px",
fontWeight: "500",
padding: "12px 24px",
textDecoration: "none",
display: "inline-block",
textAlign: "center" as const,
minWidth: "120px",
boxSizing: "border-box" as const,
float: "left" as const,
clear: "both" as const,
}}
>
{children}
</a>
);
}

View File

@@ -0,0 +1,43 @@
import React from "react";
import { Container, Text } from "jsx-email";
interface EmailFooterProps {
companyName?: string;
supportUrl?: string;
}
export function EmailFooter({
companyName = "Unsend",
supportUrl = "https://unsend.dev"
}: EmailFooterProps) {
return (
<Container
style={{
padding: "20px 0",
backgroundColor: "#ffffff",
}}
>
<Text
style={{
fontSize: "14px",
color: "#6b7280",
textAlign: "left" as const,
margin: "0",
lineHeight: "1.5",
}}
>
This email was sent by {companyName}. If you have any questions, please{" "}
<a
href={supportUrl}
style={{
color: "#000000",
textDecoration: "underline",
}}
>
contact our support team
</a>
.
</Text>
</Container>
);
}

View File

@@ -0,0 +1,45 @@
import React from "react";
import { Container, Heading, Img } from "jsx-email";
interface EmailHeaderProps {
logoUrl?: string;
title?: string;
}
export function EmailHeader({ logoUrl, title }: EmailHeaderProps) {
return (
<Container
style={{
padding: "20px 0",
textAlign: "left" as const,
}}
>
{logoUrl && (
<Img
src={logoUrl}
alt="Unsend"
style={{
width: "48px",
height: "48px",
margin: "0 0 16px 0",
display: "block",
}}
/>
)}
{title && (
<Heading
style={{
fontSize: "24px",
fontWeight: "600",
color: "#111827",
margin: "0",
lineHeight: "1.3",
textAlign: "left" as const,
}}
>
{title}
</Heading>
)}
</Container>
);
}

View File

@@ -0,0 +1,77 @@
import React from "react";
import {
Html,
Head,
Body,
Container,
Font,
Preview,
} from "jsx-email";
interface EmailLayoutProps {
preview?: string;
children: React.ReactNode;
}
export function EmailLayout({ preview, children }: EmailLayoutProps) {
return (
<Html>
<Head>
<Font
fallbackFontFamily="sans-serif"
fontFamily="Inter"
fontStyle="normal"
fontWeight={400}
webFont={{
url: "https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.19",
format: "woff2",
}}
/>
<style
dangerouslySetInnerHTML={{
__html: `
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #374151;
background-color: #f9fafb;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
}
`,
}}
/>
<meta content="width=device-width" name="viewport" />
<meta content="IE=edge" httpEquiv="X-UA-Compatible" />
<meta name="x-apple-disable-message-reformatting" />
<meta
content="telephone=no,address=no,email=no,date=no,url=no"
name="format-detection"
/>
<meta content="light" name="color-scheme" />
<meta content="light" name="supported-color-schemes" />
</Head>
{preview && <Preview>{preview}</Preview>}
<Body style={{ backgroundColor: "#ffffff", padding: "20px" }}>
<Container
className="email-container"
style={{
maxWidth: "600px",
backgroundColor: "#ffffff",
textAlign: "left" as const,
}}
>
{children}
</Container>
</Body>
</Html>
);
}

View File

@@ -0,0 +1,7 @@
export { OtpEmail, renderOtpEmail } from "./OtpEmail";
export { TeamInviteEmail, renderTeamInviteEmail } from "./TeamInviteEmail";
export * from "./components/EmailLayout";
export * from "./components/EmailHeader";
export * from "./components/EmailFooter";
export * from "./components/EmailButton";

View File

@@ -0,0 +1,36 @@
import { renderOtpEmail, renderTeamInviteEmail } from './index';
async function testEmailTemplates() {
console.log('Testing email templates...\n');
try {
// Test OTP email
const otpHtml = await renderOtpEmail({
otpCode: 'ABC123',
loginUrl: 'https://app.unsend.dev/login?token=abc123',
hostName: 'Unsend',
});
console.log('✅ OTP Email rendered successfully');
console.log(`Length: ${otpHtml.length} characters\n`);
// Test Team Invite email
const inviteHtml = await renderTeamInviteEmail({
teamName: 'My Awesome Team',
inviteUrl: 'https://app.unsend.dev/join-team?inviteId=123',
});
console.log('✅ Team Invite Email rendered successfully');
console.log(`Length: ${inviteHtml.length} characters\n`);
console.log('🎉 All email templates are working correctly!');
} catch (error) {
console.error('❌ Error testing email templates:', error);
process.exit(1);
}
}
if (require.main === module) {
testEmailTemplates();
}

View File

@@ -5,6 +5,7 @@ import { db } from "./db";
import { getDomains } from "./service/domain-service";
import { sendEmail } from "./service/email-service";
import { logger } from "./logger/log";
import { renderOtpEmail, renderTeamInviteEmail } from "./email-templates";
let unsend: Unsend | undefined;
@@ -28,8 +29,16 @@ export async function sendSignUpEmail(
}
const subject = "Sign in to Unsend";
// Use jsx-email template for beautiful HTML
const html = await renderOtpEmail({
otpCode: token.toUpperCase(),
loginUrl: url,
hostName: host,
});
// Fallback text version
const text = `Hey,\n\nYou can sign in to Unsend by clicking the below URL:\n${url}\n\nYou can also use this OTP: ${token}\n\nThanks,\nUnsend Team`;
const html = `<p>Hey,</p> <p>You can sign in to Unsend by clicking the below URL:</p><p><a href="${url}">Sign in to ${host}</a></p><p>You can also use this OTP: <b>${token}</b></p<br /><br /><p>Thanks,</p><p>Unsend Team</p>`;
await sendMail(email, subject, text, html);
}
@@ -47,8 +56,15 @@ export async function sendTeamInviteEmail(
}
const subject = "You have been invited to join a team";
// Use jsx-email template for beautiful HTML
const html = await renderTeamInviteEmail({
teamName,
inviteUrl: url,
});
// Fallback text version
const text = `Hey,\n\nYou have been invited to join the team ${teamName} on Unsend.\n\nYou can accept the invitation by clicking the below URL:\n${url}\n\nThanks,\nUnsend Team`;
const html = `<p>Hey,</p> <p>You have been invited to join the team <b>${teamName}</b> on Unsend.</p><p>You can accept the invitation by clicking the below URL:</p><p><a href="${url}">Accept invitation</a></p><br /><br /><p>Thanks,</p><p>Unsend Team</p>`;
await sendMail(email, subject, text, html);
}