mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-21 18:39:05 +02:00
feat: update the design of the email templates
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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, '"');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
};
|
||||
|
||||
Generated
+323
-328
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user