mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-15 07:17:00 +10:00
feat: Add better email templates for password reset and email verification.
This commit is contained in:
@@ -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