feat: update the design of the email templates

This commit is contained in:
Elias Schneider
2026-09-20 16:07:29 +02:00
parent 8fe53ed42c
commit 3249b0dc8c
28 changed files with 788 additions and 627 deletions
+2
View File
@@ -36,6 +36,7 @@ type template[V any] struct {
type templateData[V any] struct {
AppName string
LogoURL string
AppURL string
Data *V
}
@@ -138,6 +139,7 @@ func send[V any](ctx context.Context, module *Module, dbConfig *appconfig.AppCon
templateData := &templateData[V]{
AppName: dbConfig.AppName.String(),
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
AppURL: common.EnvConfig.AppURL,
Data: data,
}
+1 -1
View File
@@ -67,7 +67,7 @@ func TestModuleSendsEveryEmailType(t *testing.T) {
{
name: "email verification",
subject: "Verify your Pocket ID Test email address",
bodyContains: []string{"EMAIL VERIFICATION", "Hello Test User", "https://id.example.test/verify-token"},
bodyContains: []string{"VERIFY YOUR EMAIL ADDRESS", "Hello Test User", "https://id.example.test/verify-token"},
send: func(ctx context.Context, config *appconfig.AppConfigModel) error {
return module.SendEmailVerification(ctx, config, user.FullName(), userEmail, "https://id.example.test/verify-token")
},
File diff suppressed because one or more lines are too long
@@ -1,11 +1,7 @@
{{define "root"}}{{.AppName}}
API KEY EXPIRING SOON
Warning
{{define "root"}}API KEY EXPIRING SOON
Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}}
Your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. Anything that uses this key will stop working once it expires.
To keep access, create a new API key in your {{.AppName}} account settings before then.{{end}}
File diff suppressed because one or more lines are too long
@@ -1,10 +1,10 @@
{{define "root"}}{{.AppName}}
EMAIL VERIFICATION
{{define "root"}}VERIFY YOUR EMAIL ADDRESS
Hello {{.Data.UserFullName}},
Click the button below to verify your email address for {{.AppName}}. This link will expire in 24 hours.
Click the button below to confirm the email address for your {{.AppName}} account. This link expires in 24 hours.
Verify {{.Data.VerificationLink}}{{end}}
Verify email address {{.Data.VerificationLink}}
Or if you don't like clicking buttons, open this link:
{{.Data.VerificationLink}}{{end}}
File diff suppressed because one or more lines are too long
@@ -1,26 +1,17 @@
{{define "root"}}{{.AppName}}
{{define "root"}}NEW SIGN-IN DETECTED
Your {{.AppName}} account was recently accessed from a new IP address or browser. If this was you, no further action is needed.
NEW SIGN-IN DETECTED
Warning
Your {{.AppName}} account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.
DETAILS
Approximate Location
Approximate location
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address
IP address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-In Time
Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}{{end}}
If you don't recognize this activity, review the passkeys in your {{.AppName}} account settings and remove any you don't recognize.{{end}}
File diff suppressed because one or more lines are too long
@@ -1,11 +1,9 @@
{{define "root"}}{{.AppName}}
{{define "root"}}YOUR LOGIN CODE
Use the code below to sign in to {{.AppName}}. It expires in {{.Data.ExpirationString}}.
YOUR LOGIN CODE
{{.Data.Code}}
Click the button below to sign in to {{.AppName}} with a login code.
Or visit {{.Data.LoginLink}} and enter the code {{.Data.Code}}.
Sign in {{.Data.LoginLinkWithCode}}
This code expires in {{.Data.ExpirationString}}.
Sign In {{.Data.LoginLinkWithCode}}{{end}}
Or open {{.Data.LoginLink}} and enter the code manually.{{end}}
File diff suppressed because one or more lines are too long
@@ -1,6 +1,3 @@
{{define "root"}}{{.AppName}}
TEST EMAIL
{{define "root"}}TEST EMAIL
Your email setup is working correctly!{{end}}
+5 -4
View File
@@ -1,4 +1,4 @@
import { render } from "@react-email/components";
import { render } from "react-email";
import * as fs from "node:fs";
import * as path from "node:path";
@@ -17,9 +17,10 @@ async function buildTemplateFile(
templateName: string,
isPlainText: boolean
) {
const rendered = await render(Component(Component.TemplateProps), {
plainText: isPlainText,
});
const rendered = await render(
Component(Component.TemplateProps),
isPlainText ? { plainText: true } : {},
);
// Normalize quotes
const normalized = rendered.replace(/"/g, '"');
+87 -49
View File
@@ -5,77 +5,115 @@ import {
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from "@react-email/components";
} from "react-email";
import type { SharedProps } from "../props";
import { colors, fonts, radius } from "./theme";
interface BaseTemplateProps {
logoURL?: string;
appName: string;
interface BaseTemplateProps extends SharedProps {
preview?: string;
children: React.ReactNode;
}
export const BaseTemplate = ({
logoURL,
appName,
appURL,
preview,
children,
}: BaseTemplateProps) => {
return (
<Html>
<Head />
<Body style={mainStyle}>
<Container style={{ width: "500px", margin: "0 auto" }}>
<Section>
<Row
align="left"
style={{
marginBottom: "16px",
}}
>
<Column style={{ width: "50px" }}>
}: BaseTemplateProps) => (
<Html lang="en">
<Head>
<meta name="color-scheme" content="light" />
<meta name="supported-color-schemes" content="light" />
<style dangerouslySetInnerHTML={{ __html: fontFaceCss(appURL) }} />
</Head>
{preview && <Preview>{preview}</Preview>}
<Body style={bodyStyle}>
<Container style={containerStyle}>
<Section style={headerStyle} data-skip-in-text="true">
<Row>
<Column style={logoColumnStyle}>
<Link href={appURL}>
<Img
src={logoURL}
width="32"
height="32"
width="28"
height="28"
alt={appName}
style={logoStyle}
/>
</Column>
<Column>
<Text style={titleStyle}>{appName}</Text>
</Column>
</Row>
</Section>
<div style={content}>{children}</div>
</Container>
</Body>
</Html>
);
</Link>
</Column>
<Column>
<Link href={appURL} style={appNameStyle}>
{appName}
</Link>
</Column>
</Row>
</Section>
<Section>
<Row>
<Column style={cardStyle}>{children}</Column>
</Row>
</Section>
</Container>
</Body>
</Html>
);
// The heading font is self-hosted by the app, so it loads from the same origin as the logo and works without third-party requests
// Clients without web font support fall back to the serif stack declared on the headings
const fontFaceCss = (appURL: string) =>
`@font-face{font-family:'Gloock';font-style:normal;font-weight:400;mso-font-alt:'Georgia';src:url(${appURL}/fonts/Gloock-Regular.woff) format('woff');}`;
const bodyStyle = {
margin: 0,
padding: "32px 16px",
backgroundColor: colors.background,
fontFamily: fonts.sans,
color: colors.text,
};
const mainStyle = {
padding: "50px",
backgroundColor: "#FBFBFB",
fontFamily: "Arial, sans-serif",
const containerStyle = {
width: "100%",
maxWidth: "480px",
margin: "0 auto",
};
const logoStyle = {
width: "32px",
height: "32px",
const headerStyle = {
marginBottom: "20px",
};
const logoColumnStyle = {
width: "36px",
verticalAlign: "middle",
};
const titleStyle = {
fontSize: "23px",
fontWeight: "bold",
margin: "0",
padding: "0",
const logoStyle = {
display: "block",
width: "28px",
height: "28px",
borderRadius: "6px",
};
const content = {
backgroundColor: "white",
padding: "24px",
borderRadius: "10px",
boxShadow: "0 1px 4px 0px rgba(0, 0, 0, 0.1)",
const appNameStyle = {
fontFamily: fonts.serif,
fontSize: "20px",
lineHeight: "28px",
color: colors.foreground,
textDecoration: "none",
};
const cardStyle = {
backgroundColor: colors.card,
border: `1px solid ${colors.border}`,
borderRadius: radius.card,
padding: "32px",
textAlign: "left" as const,
};
+24 -24
View File
@@ -1,33 +1,33 @@
import { Button as EmailButton } from "@react-email/components";
import { Button as EmailButton, Section } from "react-email";
import { colors, fonts, radius } from "./theme";
interface ButtonProps {
href: string;
children: React.ReactNode;
style?: React.CSSProperties;
}
export const Button = ({ href, children, style = {} }: ButtonProps) => {
const buttonStyle = {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 24px",
borderRadius: "4px",
fontSize: "15px",
fontWeight: "500",
cursor: "pointer",
marginTop: "10px",
...style,
};
export const Button = ({ href, children }: ButtonProps) => (
<Section style={containerStyle}>
<EmailButton href={href} style={buttonStyle}>
{children}
</EmailButton>
</Section>
);
return (
<div style={buttonContainer}>
<EmailButton style={buttonStyle} href={href}>
{children}
</EmailButton>
</div>
);
};
const buttonContainer = {
const containerStyle = {
margin: "24px 0 8px 0",
textAlign: "center" as const,
};
const buttonStyle = {
display: "inline-block",
padding: "12px 28px",
backgroundColor: colors.primary,
color: colors.primaryForeground,
borderRadius: radius.pill,
fontFamily: fonts.sans,
fontSize: "14px",
lineHeight: "20px",
fontWeight: 500,
textDecoration: "none",
};
+16 -30
View File
@@ -1,38 +1,24 @@
import { Column, Heading, Row, Text } from "@react-email/components";
import { Heading } from "react-email";
import { colors, fonts } from "./theme";
export default function CardHeader({
title,
warning,
}: {
interface CardHeaderProps {
title: string;
warning?: boolean;
}) {
}
export default function CardHeader({ title }: CardHeaderProps) {
return (
<Row>
<Column>
<Heading as="h1" style={titleStyle}>
{title}
</Heading>
</Column>
<Column align="right">
{warning && <Text style={warningStyle}>Warning</Text>}
</Column>
</Row>
<Heading as="h1" style={titleStyle}>
{title}
</Heading>
);
}
const titleStyle = {
fontSize: "20px",
fontWeight: "bold" as const,
margin: 0,
};
const warningStyle = {
backgroundColor: "#ffd966",
color: "#7f6000",
padding: "1px 12px",
borderRadius: "50px",
fontSize: "12px",
display: "inline-block",
margin: 0,
margin: "0 0 16px 0",
fontFamily: fonts.serif,
fontSize: "26px",
lineHeight: "32px",
fontWeight: 400,
letterSpacing: "-0.01em",
color: colors.foreground,
};
+39
View File
@@ -0,0 +1,39 @@
import { Column, Row, Section, Text } from "react-email";
import { colors, fonts, radius } from "./theme";
interface CodeBoxProps {
code: string;
}
export const CodeBox = ({ code }: CodeBoxProps) => (
<Section style={boxStyle}>
<Row>
<Column style={cellStyle}>
<Text style={codeStyle}>{code}</Text>
</Column>
</Row>
</Section>
);
const boxStyle = {
margin: "8px 0 0 0",
backgroundColor: colors.muted,
border: `1px solid ${colors.border}`,
borderRadius: radius.box,
};
const cellStyle = {
padding: "20px 16px",
textAlign: "center" as const,
};
const codeStyle = {
margin: 0,
fontFamily: fonts.mono,
fontSize: "32px",
lineHeight: "40px",
fontWeight: 600,
letterSpacing: "8px",
paddingLeft: "8px",
color: colors.foreground,
};
@@ -0,0 +1,54 @@
import { Column, Row, Section } from "react-email";
import { colors, radius } from "./theme";
interface DetailsListProps {
items: { label: string; value: string }[];
}
// A key-value list inside a muted box, used for sign-in details and similar metadata
// Each value sits below its label so long values never fight the label for width on narrow screens
// Every item is its own Row so the plain-text build separates entries with a blank line
export const DetailsList = ({ items }: DetailsListProps) => (
<Section style={boxStyle}>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<Row key={item.label}>
<Column style={isLast ? cellStyle : { ...cellStyle, ...dividerStyle }}>
<span style={labelStyle}>{item.label}</span>
<br />
<span style={valueStyle}>{item.value}</span>
</Column>
</Row>
);
})}
</Section>
);
const boxStyle = {
margin: "8px 0 0 0",
backgroundColor: colors.muted,
border: `1px solid ${colors.border}`,
borderRadius: radius.box,
};
const cellStyle = {
padding: "12px 16px",
};
const labelStyle = {
fontSize: "12px",
lineHeight: "18px",
color: colors.mutedForeground,
};
const valueStyle = {
fontSize: "14px",
lineHeight: "20px",
fontWeight: 500,
color: colors.foreground,
};
const dividerStyle = {
borderBottom: `1px solid ${colors.border}`,
};
+29
View File
@@ -0,0 +1,29 @@
import { Text } from "react-email";
import { colors } from "./theme";
interface TextProps {
children: React.ReactNode;
style?: React.CSSProperties;
}
export const Paragraph = ({ children, style }: TextProps) => (
<Text style={{ ...paragraphStyle, ...style }}>{children}</Text>
);
export const Muted = ({ children, style }: TextProps) => (
<Text style={{ ...mutedStyle, ...style }}>{children}</Text>
);
const paragraphStyle = {
margin: "0 0 16px 0",
fontSize: "15px",
lineHeight: "24px",
color: colors.text,
};
const mutedStyle = {
margin: "16px 0 0 0",
fontSize: "13px",
lineHeight: "20px",
color: colors.mutedForeground,
};
+25
View File
@@ -0,0 +1,25 @@
// Design tokens mirrored from the frontend's app.css (light theme) so emails match the app
// Email clients have no CSS variable or oklch support, so the values are the resolved hex equivalents
export const colors = {
background: "#f5f5f5",
card: "#ffffff",
border: "#e5e5e5",
muted: "#fafafa",
foreground: "#0a0a0a",
text: "#404040",
mutedForeground: "#737373",
primary: "#171717",
primaryForeground: "#ffffff",
};
export const fonts = {
sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Gloock, Georgia, 'Times New Roman', serif",
mono: "'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace",
};
export const radius = {
card: "24px",
box: "16px",
pill: "9999px",
};
@@ -1,7 +1,11 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
import { Muted, Paragraph } from "../components/text";
import {
type SharedProps,
sharedPreviewProps,
sharedTemplateProps,
} from "../props";
interface ApiKeyExpiringData {
name: string;
@@ -9,28 +13,30 @@ interface ApiKeyExpiringData {
expiresAt: string;
}
interface ApiKeyExpiringEmailProps {
logoURL: string;
appName: string;
interface ApiKeyExpiringEmailProps extends SharedProps {
data: ApiKeyExpiringData;
}
export const ApiKeyExpiringEmail = ({
logoURL,
appName,
data,
...props
}: ApiKeyExpiringEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="API Key Expiring Soon" warning />
<Text>
Hello {data.name}, <br />
This is a reminder that your API key <strong>
{data.apiKeyName}
</strong>{" "}
will expire on <strong>{data.expiresAt}</strong>.
</Text>
<BaseTemplate
{...props}
preview={`Your API key ${data.apiKeyName} expires on ${data.expiresAt}`}
>
<CardHeader title="API key expiring soon" />
<Paragraph>Hello {data.name},</Paragraph>
<Paragraph>
Your API key <strong>{data.apiKeyName}</strong> will expire on{" "}
<strong>{data.expiresAt}</strong>. Anything that uses this key will stop
working once it expires.
</Paragraph>
<Text>Please generate a new API key if you need continued access.</Text>
<Muted>
To keep access, create a new API key in your {props.appName} account
settings before then.
</Muted>
</BaseTemplate>
);
@@ -48,8 +54,8 @@ ApiKeyExpiringEmail.TemplateProps = {
ApiKeyExpiringEmail.PreviewProps = {
...sharedPreviewProps,
data: {
name: "Elias Schneider",
apiKeyName: "My API Key",
expiresAt: "September 30, 2024",
name: "Elias",
apiKeyName: "CI deploy key",
expiresAt: "2026-01-30 12:00:00 UTC",
},
};
+35 -17
View File
@@ -1,41 +1,59 @@
import { Text } from "@react-email/components";
import { Link } from "react-email";
import { BaseTemplate } from "../components/base-template";
import { Button } from "../components/button";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
import { Muted, Paragraph } from "../components/text";
import { colors } from "../components/theme";
import {
type SharedProps,
sharedPreviewProps,
sharedTemplateProps,
} from "../props";
interface EmailVerificationData {
userFullName: string;
verificationLink: string;
}
interface EmailVerificationProps {
logoURL: string;
appName: string;
interface EmailVerificationProps extends SharedProps {
data: EmailVerificationData;
}
export const EmailVerification = ({
logoURL,
appName,
data,
...props
}: EmailVerificationProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Email Verification" />
<BaseTemplate
{...props}
preview={`Confirm the email address for your ${props.appName} account`}
>
<CardHeader title="Verify your email address" />
<Paragraph>Hello {data.userFullName},</Paragraph>
<Paragraph>
Click the button below to confirm the email address for your{" "}
{props.appName} account. This link expires in 24 hours.
</Paragraph>
<Text>
Hello {data.userFullName}, <br />
Click the button below to verify your email address for {appName}. This
link will expire in 24 hours.
<Button href={data.verificationLink}>Verify email address</Button>
<Muted style={{ marginTop: "32px" }}>
Or if you don't like clicking buttons, open this link:
<br />
</Text>
<Button href={data.verificationLink}>Verify</Button>
<Link href={data.verificationLink} style={linkStyle}>
{data.verificationLink}
</Link>
</Muted>
</BaseTemplate>
);
export default EmailVerification;
const linkStyle = {
color: colors.mutedForeground,
textDecoration: "underline",
wordBreak: "break-all" as const,
};
EmailVerification.TemplateProps = {
...sharedTemplateProps,
data: {
@@ -49,6 +67,6 @@ EmailVerification.PreviewProps = {
data: {
userFullName: "Tim Cook",
verificationLink:
"https://localhost:1411/user/verify-email?code=abcdefg12345",
"https://id.example.com/user/verify-email?code=abcdefg12345",
},
};
@@ -1,7 +1,12 @@
import { Column, Heading, Row, Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
import { DetailsList } from "../components/details-list";
import { Muted, Paragraph } from "../components/text";
import {
type SharedProps,
sharedPreviewProps,
sharedTemplateProps,
} from "../props";
interface SignInData {
location: string;
@@ -10,78 +15,44 @@ interface SignInData {
dateTime: string;
}
interface NewSignInEmailProps {
logoURL: string;
appName: string;
interface NewSignInEmailProps extends SharedProps {
data: SignInData;
}
export const NewSignInEmail = ({
logoURL,
appName,
data,
}: NewSignInEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="New Sign-In Detected" warning />
<Text>
Your {appName} account was recently accessed from a new IP address or
browser. If you recognize this activity, no further action is required.
</Text>
<Heading
style={{
fontSize: "1rem",
fontWeight: "bold",
margin: "30px 0 10px 0",
}}
as="h4"
>
Details
</Heading>
export const NewSignInEmail = ({ data, ...props }: NewSignInEmailProps) => (
<BaseTemplate
{...props}
preview={`A new sign-in to your ${props.appName} account was detected`}
>
<CardHeader title="New sign-in detected" />
<Paragraph>
Your {props.appName} account was recently accessed from a new IP address
or browser. If this was you, no further action is needed.
</Paragraph>
<Row>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Approximate Location</Text>
<Text style={detailsBoxValueStyle}>{data.location}</Text>
</Column>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>IP Address</Text>
<Text style={detailsBoxValueStyle}>{data.ipAddress}</Text>
</Column>
</Row>
<DetailsList
items={[
{ label: "Approximate location", value: data.location },
{ label: "IP address", value: data.ipAddress },
{ label: "Device", value: data.device },
{ label: "Time", value: data.dateTime },
]}
/>
<Row style={{ marginTop: "10px" }}>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Device</Text>
<Text style={detailsBoxValueStyle}>{data.device}</Text>
</Column>
<Column style={detailsBoxStyle}>
<Text style={detailsLabelStyle}>Sign-In Time</Text>
<Text style={detailsBoxValueStyle}>{data.dateTime}</Text>
</Column>
</Row>
<Muted>
If you don't recognize this activity, review the passkeys in your{" "}
{props.appName} account settings and remove any you don't recognize.
</Muted>
</BaseTemplate>
);
export default NewSignInEmail;
const detailsBoxStyle = {
width: "225px",
};
const detailsLabelStyle = {
margin: 0,
fontSize: "12px",
color: "gray",
};
const detailsBoxValueStyle = {
margin: 0,
};
NewSignInEmail.TemplateProps = {
...sharedTemplateProps,
data: {
location: "{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}",
location:
"{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}",
ipAddress: "{{.Data.IPAddress}}",
device: "{{.Data.Device}}",
dateTime: '{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}',
@@ -92,8 +63,8 @@ NewSignInEmail.PreviewProps = {
...sharedPreviewProps,
data: {
location: "San Francisco, USA",
ipAddress: "127.0.0.1",
ipAddress: "203.0.113.42",
device: "Chrome on macOS",
dateTime: "2024-01-01 12:00 PM UTC",
dateTime: "January 2, 2026 at 3:04 PM UTC",
},
};
+32 -24
View File
@@ -1,8 +1,15 @@
import { Link, Text } from "@react-email/components";
import { Link } from "react-email";
import { BaseTemplate } from "../components/base-template";
import { Button } from "../components/button";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
import { CodeBox } from "../components/code-box";
import { Muted, Paragraph } from "../components/text";
import { colors } from "../components/theme";
import {
type SharedProps,
sharedPreviewProps,
sharedTemplateProps,
} from "../props";
interface OneTimeAccessData {
code: string;
@@ -11,43 +18,44 @@ interface OneTimeAccessData {
expirationString: string;
}
interface OneTimeAccessEmailProps {
logoURL: string;
appName: string;
interface OneTimeAccessEmailProps extends SharedProps {
data: OneTimeAccessData;
}
export const OneTimeAccessEmail = ({
logoURL,
appName,
data,
...props
}: OneTimeAccessEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Your Login Code" />
<BaseTemplate
{...props}
preview={`Your ${props.appName} login code is ${data.code}`}
>
<CardHeader title="Your login code" />
<Paragraph>
Use the code below to sign in to {props.appName}. It expires in{" "}
{data.expirationString}.
</Paragraph>
<Text>
Click the button below to sign in to {appName} with a login code.
<br />
Or visit{" "}
<CodeBox code={data.code} />
<Button href={data.buttonCodeLink}>Sign in</Button>
<Muted style={{ marginTop: "32px" }}>
Or open{" "}
<Link href={data.loginLink} style={linkStyle}>
{data.loginLink}
</Link>{" "}
and enter the code <strong>{data.code}</strong>.
<br />
<br />
This code expires in {data.expirationString}.
</Text>
<Button href={data.buttonCodeLink}>Sign In</Button>
and enter the code manually.
</Muted>
</BaseTemplate>
);
export default OneTimeAccessEmail;
const linkStyle = {
color: "#000",
color: colors.mutedForeground,
textDecoration: "underline",
fontFamily: "Arial, sans-serif",
wordBreak: "break-all" as const,
};
OneTimeAccessEmail.TemplateProps = {
@@ -64,8 +72,8 @@ OneTimeAccessEmail.PreviewProps = {
...sharedPreviewProps,
data: {
code: "123456",
loginLink: "https://example.com/login",
buttonCodeLink: "https://example.com/login?code=123456",
loginLink: "https://id.example.com/lc",
buttonCodeLink: "https://id.example.com/lc/123456",
expirationString: "15 minutes",
},
};
+10 -11
View File
@@ -1,17 +1,16 @@
import { Text } from "@react-email/components";
import { BaseTemplate } from "../components/base-template";
import CardHeader from "../components/card-header";
import { sharedPreviewProps, sharedTemplateProps } from "../props";
import { Paragraph } from "../components/text";
import {
type SharedProps,
sharedPreviewProps,
sharedTemplateProps,
} from "../props";
interface TestEmailProps {
logoURL: string;
appName: string;
}
export const TestEmail = ({ logoURL, appName }: TestEmailProps) => (
<BaseTemplate logoURL={logoURL} appName={appName}>
<CardHeader title="Test Email" />
<Text>Your email setup is working correctly!</Text>
export const TestEmail = (props: SharedProps) => (
<BaseTemplate {...props} preview="Your email setup is working correctly">
<CardHeader title="Test email" />
<Paragraph>Your email setup is working correctly!</Paragraph>
</BaseTemplate>
);
+8 -8
View File
@@ -9,16 +9,16 @@
"export": "email export"
},
"dependencies": {
"@react-email/components": "1.0.12",
"react": "^19.2.6",
"react-dom": "^19.2.6"
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-email": "6.9.5"
},
"devDependencies": {
"@react-email/preview-server": "5.2.10",
"@types/node": "^25.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-email": "6.9.5",
"tsx": "^4.22.2"
"@react-email/ui": "6.9.5",
"@types/node": "^26.6.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"tsx": "^4.23.13"
}
}
+10 -2
View File
@@ -1,9 +1,17 @@
export const sharedPreviewProps = {
export interface SharedProps {
logoURL: string;
appName: string;
appURL: string;
}
export const sharedPreviewProps: SharedProps = {
logoURL: "https://pocket-id.org/img/logo.png",
appName: "Pocket ID",
appURL: "https://id.example.com",
};
export const sharedTemplateProps = {
export const sharedTemplateProps: SharedProps = {
logoURL: "{{.LogoURL}}",
appName: "{{.AppName}}",
appURL: "{{.AppURL}}",
};
+323 -328
View File
File diff suppressed because it is too large Load Diff