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
+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,
});