feat: Add better email templates for password reset and email verification.

This commit is contained in:
Amruth Pillai
2026-04-27 10:45:44 +02:00
parent 73ec8b2ffb
commit b87f200767
10 changed files with 1288 additions and 161 deletions
-1
View File
@@ -1,7 +1,6 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll.oxc": "explicit"
},
"editor.formatOnSave": true,
+1
View File
@@ -7,6 +7,7 @@ rss: true
<Update label="v5.0.20" description="27th April 2026">
## Features & Improvements
- New Resume Template: **Meowth**, thanks to [@JamesGoslings](https://github.com/JamesGoslings). [#2923](https://github.com/amruthpillai/reactive-resume/pull/2923)
- Add better email templates for password reset and email verification.
## Maintenance
- Updated dependencies and lockfile.
+12 -8
View File
@@ -22,6 +22,7 @@
"db:studio": "drizzle-kit studio",
"dev": "vp dev",
"docs:dev": "cd docs && npx mint dev",
"email:dev": "email dev --dir src/integrations/email/templates --port 3001",
"format": "vp fmt --check",
"format:fix": "vp fmt",
"knip": "knip",
@@ -67,9 +68,9 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@tanstack/react-query": "^5.100.5",
"@tanstack/react-router": "^1.168.24",
"@tanstack/react-router": "^1.168.25",
"@tanstack/react-router-ssr-query": "^1.166.12",
"@tanstack/react-start": "^1.167.49",
"@tanstack/react-start": "^1.167.50",
"@tanstack/zod-adapter": "^1.166.9",
"@tiptap/extension-color": "^3.22.4",
"@tiptap/extension-highlight": "^3.22.4",
@@ -107,6 +108,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-email": "^6.0.0",
"react-hook-form": "^7.74.0",
"react-hotkeys-hook": "^5.2.4",
"react-resizable-panels": "^4.10.0",
@@ -134,6 +136,7 @@
"@lingui/cli": "^6.0.0",
"@lingui/format-po": "^6.0.0",
"@lingui/vite-plugin": "^6.0.0",
"@react-email/ui": "^6.0.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.2.4",
"@testing-library/react": "^16.3.2",
@@ -144,11 +147,11 @@
"@types/pg": "^8.20.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260426.1",
"@typescript/native-preview": "7.0.0-dev.20260427.1",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"babel-plugin-macros": "^3.1.0",
"drizzle-kit": "1.0.0-beta.23",
"drizzle-kit": "1.0.0-beta.22",
"happy-dom": "^20.9.0",
"jose": "^6.2.2",
"knip": "^6.7.0",
@@ -170,15 +173,16 @@
"sharp"
],
"overrides": {
"dompurify@<3.3.2": ">=3.3.2",
"serialize-javascript@<=7.0.2": ">=7.0.3",
"dompurify@>=3.1.3 <=3.3.1": ">=3.3.2",
"serialize-javascript@<7.0.5": ">=7.0.5",
"dompurify@<=3.3.1": ">=3.3.2",
"dompurify@<=3.3.3": ">=3.4.0",
"dompurify@<3.3.2": ">=3.3.2",
"dompurify@<3.4.0": ">=3.4.0",
"dompurify@>=1.0.10 <3.4.0": ">=3.4.0",
"dompurify@>=3.0.1 <3.4.0": ">=3.4.0",
"dompurify@>=3.1.3 <=3.3.1": ">=3.3.2",
"postcss@<8.5.10": ">=8.5.10",
"serialize-javascript@<=7.0.2": ">=7.0.3",
"serialize-javascript@<7.0.5": ">=7.0.5",
"uuid@<14.0.0": ">=14.0.0"
}
}
+1016 -144
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -13,6 +13,7 @@ import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { twoFactor } from "better-auth/plugins/two-factor";
import { username } from "better-auth/plugins/username";
import { eq, or } from "drizzle-orm";
import { createElement } from "react";
import { env } from "@/utils/env";
import { hashPassword, verifyPassword } from "@/utils/password";
@@ -23,6 +24,7 @@ import { schema } from "../drizzle";
import { db } from "../drizzle/client";
import { lower } from "../drizzle/helpers";
import { sendEmail } from "../email/service";
import { ResetPasswordEmail, VerifyEmail, VerifyEmailChange } from "../email/templates/auth";
export const authBaseUrl = process.env.BETTER_AUTH_URL ?? env.APP_URL;
@@ -291,7 +293,7 @@ const getAuthConfig = () => {
await sendEmail({
to: user.email,
subject: "Reset your password",
text: `You requested a password reset for your Reactive Resume account.\n\nTo reset your password, please visit the following URL:\n${url}.\n\nIf you did not request a password reset, please ignore this email.`,
react: createElement(ResetPasswordEmail, { url }),
});
},
password: {
@@ -307,7 +309,7 @@ const getAuthConfig = () => {
await sendEmail({
to: user.email,
subject: "Verify your email",
text: `You recently signed up for an account on Reactive Resume.\n\nTo verify your email, please visit the following URL:\n${url}`,
react: createElement(VerifyEmail, { url }),
});
},
},
@@ -319,7 +321,7 @@ const getAuthConfig = () => {
await sendEmail({
to: newEmail,
subject: "Verify your new email",
text: `You recently requested to change your email on Reactive Resume from ${user.email} to ${newEmail}.\n\nTo verify this change, please visit the following URL:\n${url}\n\nIf you did not request this change, please ignore this email.`,
react: createElement(VerifyEmailChange, { url, previousEmail: user.email, newEmail }),
});
},
},
+18 -5
View File
@@ -1,11 +1,16 @@
import nodemailer, { type Transporter } from "nodemailer";
import type { ReactElement } from "react";
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
import { render } from "react-email";
import { env } from "@/utils/env";
type SendEmailOptions = {
to: string | string[];
subject: string;
text: string;
text?: string;
html?: string;
react?: ReactElement;
from?: string;
};
@@ -34,27 +39,35 @@ const getTransport = () => {
export const sendEmail = async (options: SendEmailOptions) => {
const transport = getTransport();
const from = options.from ?? env.SMTP_FROM ?? "Reactive Resume <noreply@localhost>";
const payload: nodemailer.SendMailOptions = {
const payload: SendMailOptions = {
to: options.to,
from,
subject: options.subject,
text: options.text,
html: options.html,
};
if (options.react) {
payload.html = await render(options.react);
payload.text = options.text ?? (await render(options.react, { plainText: true }));
}
if (!payload.text && !payload.html) return;
if (!transport) {
console.info("SMTP not configured; skipping email send.", {
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
return;
}
try {
await transport.sendMail({ ...options, from });
await transport.sendMail(payload);
} catch (error) {
console.error("There was an error sending mail.", error);
}
+187
View File
@@ -0,0 +1,187 @@
import {
Body,
Button,
Container,
Font,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
pixelBasedPreset,
} from "react-email";
const appName = "Reactive Resume";
const logoUrl = "https://rxresu.me/icon/dark.svg";
interface AuthEmailLayoutProps {
preview: string;
heading: string;
intro: string;
details?: string;
actionLabel: string;
actionUrl: string;
outro: string;
}
function AuthEmailLayout({ preview, heading, intro, details, actionLabel, actionUrl, outro }: AuthEmailLayoutProps) {
return (
<Html lang="en">
<Tailwind
config={{
presets: [pixelBasedPreset],
theme: {
fontFamily: {
body: ["IBM Plex Sans", "sans-serif"],
heading: ["IBM Plex Sans Condensed", "sans-serif"],
},
},
}}
>
<Head>
<Font
fontFamily="IBM Plex Sans Condensed"
fallbackFontFamily="sans-serif"
fontWeight={500}
fontStyle="normal"
webFont={{
url: "https://fonts.gstatic.com/s/ibmplexsans/v23/zYXGKVElMYYaJe8bpLHnCwDKr932-G7dytD-DmvrswZSAXcomDVmadSD2FlDB6g4tIOm6_De.woff2",
format: "woff2",
}}
/>
<Font
fontFamily="IBM Plex Sans"
fallbackFontFamily="sans-serif"
fontWeight={400}
fontStyle="normal"
webFont={{
url: "https://fonts.gstatic.com/s/ibmplexsans/v23/zYXGKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1swZSAXcomDVmadSD6llDB6g4tIOm6_De.woff2",
format: "woff2",
}}
/>
</Head>
<Body className="font-body m-0 bg-zinc-950 p-0 text-sm text-zinc-50">
<Preview>{preview}</Preview>
<Container className="mx-auto w-full max-w-xl bg-zinc-900 p-6 text-zinc-50">
<Section>
<Img src={logoUrl} alt={appName} width="48" height="48" className="block" />
</Section>
<Section className="mt-6">
<Heading className="font-heading text-2xl leading-0 font-medium tracking-tighter whitespace-break-spaces md:text-5xl">
{heading}
</Heading>
<Section className="mt-6 md:mt-12">
<Text>{intro}</Text>
{details ? <Text className="opacity-60">{details}</Text> : null}
</Section>
<Button
target="_blank"
href={actionUrl}
className="mt-6 box-border inline-block bg-zinc-200 px-6 py-3 text-center text-zinc-900 no-underline"
>
{actionLabel}
</Button>
<Section className="mt-8">
<Text className="leading-0">
If the button does not work, copy and paste this link into your browser:
</Text>
<Link className="leading-0 text-zinc-200/60 underline underline-offset-2" href={actionUrl}>
{actionUrl}
</Link>
</Section>
<Section className="mt-4">
<Text className="opacity-60">{outro}</Text>
</Section>
<Hr className="my-10 border-zinc-700" />
<Text className="mt-8 text-xs leading-1 opacity-40">By the community, for the community.</Text>
<Text className="text-xs leading-1 opacity-40">
A passion project by{" "}
<Link
target="_blank"
rel="noopener noreferrer"
href="https://amruthpillai.com"
className="text-inherit underline underline-offset-2"
>
Amruth Pillai
</Link>
.
</Text>
<Text className="font-heading mt-8 text-base font-medium tracking-tight opacity-80">Reactive Resume</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
interface ResetPasswordEmailProps {
url: string;
}
export function ResetPasswordEmail({ url }: ResetPasswordEmailProps) {
return (
<AuthEmailLayout
preview={`Reset your ${appName} password`}
heading="Password Reset"
intro={`We received a request to reset your ${appName} password.`}
details="If this was not you, you can ignore this message and your password will remain unchanged."
actionLabel="Create New Password"
actionUrl={url}
outro="For security, only use links from emails sent by Reactive Resume."
/>
);
}
interface VerifyEmailProps {
url: string;
}
export function VerifyEmail({ url }: VerifyEmailProps) {
return (
<AuthEmailLayout
preview={`Verify your email for ${appName}`}
heading="Verify Email"
intro={`Thanks for signing up for ${appName}. Please verify your email address to continue.`}
details="Verification helps us protect your account and keep your sign-in secure."
actionLabel="Verify Email"
actionUrl={url}
outro="If you did not create this account, you can safely ignore this email."
/>
);
}
interface VerifyEmailChangeProps {
url: string;
previousEmail: string;
newEmail: string;
}
export function VerifyEmailChange({ url, previousEmail, newEmail }: VerifyEmailChangeProps) {
return (
<AuthEmailLayout
preview={`Confirm your new ${appName} email address`}
heading="Confirm Email Change"
intro={`You requested to change your ${appName} email from ${previousEmail} to ${newEmail}.`}
details="Confirm this change to complete the update and keep your account access uninterrupted."
actionLabel="Verify New Email"
actionUrl={url}
outro="If you did not request this change, ignore this email and secure your account."
/>
);
}
@@ -0,0 +1,15 @@
import { ResetPasswordEmail } from "./auth";
interface ResetPasswordTemplateProps {
url: string;
}
const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
return <ResetPasswordEmail url={url} />;
};
export default Object.assign(ResetPasswordTemplate, {
PreviewProps: {
url: "https://localhost:3000/auth/reset-password?token=example-token",
} satisfies ResetPasswordTemplateProps,
});
@@ -0,0 +1,19 @@
import { VerifyEmailChange } from "./auth";
interface VerifyEmailChangeTemplateProps {
url: string;
previousEmail: string;
newEmail: string;
}
const VerifyEmailChangeTemplate = ({ url, previousEmail, newEmail }: VerifyEmailChangeTemplateProps) => {
return <VerifyEmailChange url={url} previousEmail={previousEmail} newEmail={newEmail} />;
};
export default Object.assign(VerifyEmailChangeTemplate, {
PreviewProps: {
url: "https://localhost:3000/auth/verify-email-change?token=example-token",
previousEmail: "old@example.com",
newEmail: "new@example.com",
} satisfies VerifyEmailChangeTemplateProps,
});
@@ -0,0 +1,15 @@
import { VerifyEmail } from "./auth";
interface VerifyEmailTemplateProps {
url: string;
}
const VerifyEmailTemplate = ({ url }: VerifyEmailTemplateProps) => {
return <VerifyEmail url={url} />;
};
export default Object.assign(VerifyEmailTemplate, {
PreviewProps: {
url: "https://localhost:3000/auth/verify-email?token=example-token",
} satisfies VerifyEmailTemplateProps,
});