mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat: Add better email templates for password reset and email verification.
This commit is contained in:
Vendored
-1
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.fixAll.oxc": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1016
-144
File diff suppressed because it is too large
Load Diff
@@ -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 }),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user