mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-06 05:12:57 +03:00
refactor: use react email for email templates (#734)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
115
email-templates/build.ts
Normal file
115
email-templates/build.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { render } from "@react-email/components";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const outputDir = "../backend/resources/email-templates";
|
||||
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
function getTemplateName(filename: string): string {
|
||||
return filename.replace(".tsx", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag-aware wrapping:
|
||||
* - Prefer breaking immediately after the last '>' within maxLen.
|
||||
* - Never break at spaces.
|
||||
* - If no '>' exists in the window, hard-break at maxLen.
|
||||
*/
|
||||
function tagAwareWrap(input: string, maxLen: number): string {
|
||||
const out: string[] = [];
|
||||
|
||||
for (const originalLine of input.split(/\r?\n/)) {
|
||||
let line = originalLine;
|
||||
while (line.length > maxLen) {
|
||||
let breakPos = line.lastIndexOf(">", maxLen);
|
||||
|
||||
// If '>' happens to be exactly at maxLen, break after it
|
||||
if (breakPos === maxLen) breakPos = maxLen;
|
||||
|
||||
// If we found a '>' before the limit, break right after it
|
||||
if (breakPos > -1 && breakPos < maxLen) {
|
||||
out.push(line.slice(0, breakPos + 1));
|
||||
line = line.slice(breakPos + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// No suitable tag end found—hard break
|
||||
out.push(line.slice(0, maxLen));
|
||||
line = line.slice(maxLen);
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
async function buildTemplateFile(
|
||||
Component: any,
|
||||
templateName: string,
|
||||
isPlainText: boolean
|
||||
) {
|
||||
const rendered = await render(Component(Component.TemplateProps), {
|
||||
plainText: isPlainText,
|
||||
});
|
||||
|
||||
// Normalize quotes
|
||||
const normalized = rendered.replace(/"/g, '"');
|
||||
|
||||
// Enforce line length: prefer tag boundaries, never spaces
|
||||
const maxLen = isPlainText ? 78 : 998; // RFC-safe
|
||||
const safe = tagAwareWrap(normalized, maxLen);
|
||||
|
||||
const goTemplate = `{{define "root"}}${safe}{{end}}`;
|
||||
const suffix = isPlainText ? "_text.tmpl" : "_html.tmpl";
|
||||
const templatePath = path.join(outputDir, `${templateName}${suffix}`);
|
||||
|
||||
fs.writeFileSync(templatePath, goTemplate);
|
||||
}
|
||||
|
||||
async function discoverAndBuildTemplates() {
|
||||
console.log("Discovering and building email templates...");
|
||||
|
||||
const emailsDir = "./emails";
|
||||
const files = fs.readdirSync(emailsDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".tsx")) continue;
|
||||
|
||||
const templateName = getTemplateName(file);
|
||||
const modulePath = `./${emailsDir}/${file}`;
|
||||
|
||||
console.log(`Building ${templateName}...`);
|
||||
|
||||
try {
|
||||
const module = await import(modulePath);
|
||||
const Component = module.default || module[Object.keys(module)[0]];
|
||||
|
||||
if (!Component) {
|
||||
console.error(`✗ No component found in ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Component.TemplateProps) {
|
||||
console.error(`✗ No TemplateProps found in ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await buildTemplateFile(Component, templateName, false); // HTML
|
||||
await buildTemplateFile(Component, templateName, true); // Text
|
||||
|
||||
console.log(`✓ Built ${templateName}`);
|
||||
} catch (error) {
|
||||
console.error(`✗ Error building ${templateName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await discoverAndBuildTemplates();
|
||||
console.log("All templates built successfully!");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
87
email-templates/components/base-template.tsx
Normal file
87
email-templates/components/base-template.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface BaseTemplateProps {
|
||||
logoURL?: string;
|
||||
appName: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BaseTemplate = ({
|
||||
logoURL,
|
||||
appName,
|
||||
children,
|
||||
}: BaseTemplateProps) => {
|
||||
const finalLogoURL =
|
||||
logoURL ||
|
||||
"https://private-user-images.githubusercontent.com/58886915/359183039-4ceb2708-9f29-4694-b797-be833efce17d.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTY0NTk5MzksIm5iZiI6MTc1NjQ1OTYzOSwicGF0aCI6Ii81ODg4NjkxNS8zNTkxODMwMzktNGNlYjI3MDgtOWYyOS00Njk0LWI3OTctYmU4MzNlZmNlMTdkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA4MjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwODI5VDA5MjcxOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWM4ZWI5NzlkMDA5NDNmZGU5MjQwMGE1YjA0NWZiNzEzM2E0MzAzOTFmOWRmNDUzNmJmNjQwZTMxNGIzZmMyYmQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.YdfLv1tD5KYnRZPSA3QlR1SsvScpP0rt-J3YD6ZHsCk";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={mainStyle}>
|
||||
<Container style={{ width: "500px", margin: "0 auto" }}>
|
||||
<Section>
|
||||
<Row
|
||||
align="left"
|
||||
style={{
|
||||
width: "210px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<Column>
|
||||
<Img
|
||||
src={finalLogoURL}
|
||||
width="32"
|
||||
height="32"
|
||||
alt={appName}
|
||||
style={logoStyle}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={titleStyle}>{appName}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<div style={content}>{children}</div>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
const mainStyle = {
|
||||
padding: "50px",
|
||||
backgroundColor: "#FBFBFB",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
};
|
||||
|
||||
const logoStyle = {
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
verticalAlign: "middle",
|
||||
marginRight: "8px",
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
fontSize: "23px",
|
||||
fontWeight: "bold",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
};
|
||||
|
||||
const content = {
|
||||
backgroundColor: "white",
|
||||
padding: "24px",
|
||||
borderRadius: "10px",
|
||||
boxShadow: "0 1px 4px 0px rgba(0, 0, 0, 0.1)",
|
||||
};
|
||||
33
email-templates/components/button.tsx
Normal file
33
email-templates/components/button.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button as EmailButton } from "@react-email/components";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={buttonContainer}>
|
||||
<EmailButton style={buttonStyle} href={href}>
|
||||
{children}
|
||||
</EmailButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const buttonContainer = {
|
||||
textAlign: "center" as const,
|
||||
};
|
||||
38
email-templates/components/card-header.tsx
Normal file
38
email-templates/components/card-header.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Column, Heading, Row, Text } from "@react-email/components";
|
||||
|
||||
export default function CardHeader({
|
||||
title,
|
||||
warning,
|
||||
}: {
|
||||
title: string;
|
||||
warning?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Row>
|
||||
<Column>
|
||||
<Heading as="h1" style={titleStyle}>
|
||||
{title}
|
||||
</Heading>
|
||||
</Column>
|
||||
<Column align="right">
|
||||
{warning && <Text style={warningStyle}>Warning</Text>}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
55
email-templates/emails/api-key-expiring-soon.tsx
Normal file
55
email-templates/emails/api-key-expiring-soon.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
|
||||
interface ApiKeyExpiringData {
|
||||
name: string;
|
||||
apiKeyName: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface ApiKeyExpiringEmailProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
data: ApiKeyExpiringData;
|
||||
}
|
||||
|
||||
export const ApiKeyExpiringEmail = ({
|
||||
logoURL,
|
||||
appName,
|
||||
data,
|
||||
}: 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>
|
||||
|
||||
<Text>Please generate a new API key if you need continued access.</Text>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
export default ApiKeyExpiringEmail;
|
||||
|
||||
ApiKeyExpiringEmail.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
data: {
|
||||
name: "{{.Data.Name}}",
|
||||
apiKeyName: "{{.Data.APIKeyName}}",
|
||||
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
|
||||
},
|
||||
};
|
||||
|
||||
ApiKeyExpiringEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
data: {
|
||||
name: "Elias Schneider",
|
||||
apiKeyName: "My API Key",
|
||||
expiresAt: "September 30, 2024",
|
||||
},
|
||||
};
|
||||
104
email-templates/emails/login-with-new-device.tsx
Normal file
104
email-templates/emails/login-with-new-device.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
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";
|
||||
|
||||
interface SignInData {
|
||||
city?: string;
|
||||
country?: string;
|
||||
ipAddress: string;
|
||||
device: string;
|
||||
dateTime: string;
|
||||
}
|
||||
|
||||
interface NewSignInEmailProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
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>
|
||||
|
||||
<Row>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>Approximate Location</Text>
|
||||
<Text style={detailsBoxValueStyle}>
|
||||
{data.city}, {data.country}
|
||||
</Text>
|
||||
</Column>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>IP Address</Text>
|
||||
<Text style={detailsBoxValueStyle}>{data.ipAddress}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<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>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
export default NewSignInEmail;
|
||||
|
||||
const detailsBoxStyle = {
|
||||
width: "225px",
|
||||
};
|
||||
|
||||
const detailsLabelStyle = {
|
||||
margin: 0,
|
||||
fontSize: "12px",
|
||||
color: "gray",
|
||||
};
|
||||
|
||||
const detailsBoxValueStyle = {
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
NewSignInEmail.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
data: {
|
||||
city: "{{.Data.City}}",
|
||||
country: "{{.Data.Country}}",
|
||||
ipAddress: "{{.Data.IPAddress}}",
|
||||
device: "{{.Data.Device}}",
|
||||
dateTime: '{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}',
|
||||
},
|
||||
};
|
||||
|
||||
NewSignInEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
data: {
|
||||
city: "San Francisco",
|
||||
country: "USA",
|
||||
ipAddress: "127.0.0.1",
|
||||
device: "Chrome on macOS",
|
||||
dateTime: "2024-01-01 12:00 PM UTC",
|
||||
},
|
||||
};
|
||||
71
email-templates/emails/one-time-access.tsx
Normal file
71
email-templates/emails/one-time-access.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Link, Text } from "@react-email/components";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import { Button } from "../components/button";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
|
||||
interface OneTimeAccessData {
|
||||
code: string;
|
||||
loginLink: string;
|
||||
buttonCodeLink: string;
|
||||
expirationString: string;
|
||||
}
|
||||
|
||||
interface OneTimeAccessEmailProps {
|
||||
logoURL: string;
|
||||
appName: string;
|
||||
data: OneTimeAccessData;
|
||||
}
|
||||
|
||||
export const OneTimeAccessEmail = ({
|
||||
logoURL,
|
||||
appName,
|
||||
data,
|
||||
}: OneTimeAccessEmailProps) => (
|
||||
<BaseTemplate logoURL={logoURL} appName={appName}>
|
||||
<CardHeader title="Your Login Code" />
|
||||
|
||||
<Text>
|
||||
Click the button below to sign in to {appName} with a login code.
|
||||
<br />
|
||||
Or visit{" "}
|
||||
<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>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
export default OneTimeAccessEmail;
|
||||
|
||||
const linkStyle = {
|
||||
color: "#000",
|
||||
textDecoration: "underline",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
};
|
||||
|
||||
OneTimeAccessEmail.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
data: {
|
||||
code: "{{.Data.Code}}",
|
||||
loginLink: "{{.Data.LoginLink}}",
|
||||
buttonCodeLink: "{{.Data.LoginLinkWithCode}}",
|
||||
expirationString: "{{.Data.ExpirationString}}",
|
||||
},
|
||||
};
|
||||
|
||||
OneTimeAccessEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
data: {
|
||||
code: "123456",
|
||||
loginLink: "https://example.com/login",
|
||||
buttonCodeLink: "https://example.com/login?code=123456",
|
||||
expirationString: "15 minutes",
|
||||
},
|
||||
};
|
||||
26
email-templates/emails/test.tsx
Normal file
26
email-templates/emails/test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Text } from "@react-email/components";
|
||||
import { BaseTemplate } from "../components/base-template";
|
||||
import CardHeader from "../components/card-header";
|
||||
import { 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>
|
||||
</BaseTemplate>
|
||||
);
|
||||
|
||||
export default TestEmail;
|
||||
|
||||
TestEmail.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
};
|
||||
|
||||
TestEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
};
|
||||
25
email-templates/package.json
Normal file
25
email-templates/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pocketid-email-templates",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"build": "tsx build.ts",
|
||||
"build:watch": "tsx watch build.ts",
|
||||
"dev": "email dev --port 3030",
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-email/preview-server": "4.2.8",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.1",
|
||||
"react-email": "4.2.8",
|
||||
"tsx": "^4.0.0"
|
||||
}
|
||||
}
|
||||
9
email-templates/props.ts
Normal file
9
email-templates/props.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const sharedPreviewProps = {
|
||||
logoURL: "https://pocket-id.org/img/logo.png",
|
||||
appName: "Pocket ID",
|
||||
};
|
||||
|
||||
export const sharedTemplateProps = {
|
||||
logoURL: "{{.LogoURL}}",
|
||||
appName: "{{.AppName}}",
|
||||
};
|
||||
25
email-templates/tsconfig.json
Normal file
25
email-templates/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user