feat: implement beautiful jsx-email templates for OTP and team invites (#196)
Co-authored-by: opencode <noreply@opencode.ai>
This commit is contained in:
99
apps/web/src/server/email-templates/OtpEmail.tsx
Normal file
99
apps/web/src/server/email-templates/OtpEmail.tsx
Normal 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} />);
|
||||
}
|
90
apps/web/src/server/email-templates/TeamInviteEmail.tsx
Normal file
90
apps/web/src/server/email-templates/TeamInviteEmail.tsx
Normal 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} />);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
7
apps/web/src/server/email-templates/index.ts
Normal file
7
apps/web/src/server/email-templates/index.ts
Normal 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";
|
36
apps/web/src/server/email-templates/test.ts
Normal file
36
apps/web/src/server/email-templates/test.ts
Normal 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();
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
Reference in New Issue
Block a user