feat: enhance email internationalization and template system

- Add comprehensive email template system with React Email and use-intl
- Implement dynamic email template rendering with locale-specific translations
- Create reusable email components for consistent design
- Update authentication flow to support multilingual email templates
- Add new email-related dependencies and configuration
- Refactor email sending logic to support dynamic locale detection
This commit is contained in:
javayhu 2025-03-07 23:34:02 +08:00
parent 8aaead0245
commit 95ddd0a1dc
27 changed files with 536 additions and 88 deletions

View File

@ -66,5 +66,27 @@
"tableOfContents": "Table of Contents",
"all": "All",
"noPostsFound": "No posts found"
}
},
"mail": {
"common": {
"team": "{name} Team",
"copyright": "Copyright {year} All Rights Reserved."
},
"verifyEmail": {
"title": "Hi, {name}.",
"body": "Please click the link below to verify your email address.",
"confirmEmail": "Confirm email",
"subject": "Verify your email"
},
"forgotPassword": {
"title": "Hi, {name}.",
"body": "Please click the link below to reset your password.",
"resetPassword": "Reset password",
"subject": "Reset your password"
},
"subscribeNewsletter": {
"body": "Thank you for subscribing to the newsletter. We will keep you updated with the latest news and updates.",
"subject": "Thanks for subscribing"
}
}
}

View File

@ -66,5 +66,27 @@
"tableOfContents": "目录",
"all": "全部",
"noPostsFound": "没有找到文章"
}
},
"mail": {
"common": {
"team": "{name} 团队",
"copyright": "版权所有 {year}。保留所有权利。"
},
"verifyEmail": {
"title": "你好, {name}.",
"body": "请点击下面的链接验证您的邮箱地址。",
"confirmEmail": "验证邮箱",
"subject": "验证您的邮箱"
},
"forgotPassword": {
"title": "你好, {name}.",
"body": "请点击下面的链接重置您的密码。",
"resetPassword": "重置密码",
"subject": "重置您的密码"
},
"subscribeNewsletter": {
"body": "感谢您订阅我们的邮件列表,我们将持续为您提供最新新闻和更新。",
"subject": "感谢您的订阅"
}
}
}

View File

@ -7,7 +7,7 @@
"build": "content-collections build && next build",
"start": "next start",
"lint": "next lint",
"email": "email dev --dir emails --port 3333"
"email": "email dev --dir src/mail/emails --port 3333"
},
"dependencies": {
"@ai-sdk/openai": "^1.1.13",
@ -31,6 +31,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@react-email/components": "0.0.33",
"@react-email/render": "1.0.5",
"@stripe/stripe-js": "^5.6.0",
"@types/canvas-confetti": "^1.9.0",
"ai": "^4.1.45",
@ -38,6 +39,7 @@
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookie": "^1.0.2",
"date-fns": "^4.1.0",
"deepmerge": "^4.3.1",
"dotenv": "^16.4.7",
@ -69,6 +71,7 @@
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
"use-intl": "^3.26.5",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"

15
pnpm-lock.yaml generated
View File

@ -71,6 +71,9 @@ importers:
'@react-email/components':
specifier: 0.0.33
version: 0.0.33(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-email/render':
specifier: 1.0.5
version: 1.0.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@stripe/stripe-js':
specifier: ^5.6.0
version: 5.6.0
@ -92,6 +95,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cookie:
specifier: ^1.0.2
version: 1.0.2
date-fns:
specifier: ^4.1.0
version: 4.1.0
@ -185,6 +191,9 @@ importers:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
use-intl:
specifier: ^3.26.5
version: 3.26.5(react@19.0.0)
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -2573,6 +2582,10 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@ -6498,6 +6511,8 @@ snapshots:
cookie@0.7.2: {}
cookie@1.0.2: {}
cors@2.8.5:
dependencies:
object-assign: 4.1.1

View File

@ -1,7 +1,7 @@
"use client";
import {lazy} from "react";
import { lazy } from "react";
/**
* Move error content to a separate chunk and load it only when needed
*

View File

@ -38,26 +38,27 @@ export const ForgotPasswordForm = ({ className }: { className?: string }) => {
});
const onSubmit = async (values: z.infer<typeof ForgotPasswordSchema>) => {
console.log("forgotPassword, values:", values);
const { data, error } = await authClient.forgetPassword({
email: values.email,
redirectTo: `${Routes.ResetPassword}`,
}, {
onRequest: (ctx) => {
// console.log("forgotPassword, request:", ctx.url);
console.log("forgotPassword, request:", ctx.url);
setIsPending(true);
setError("");
setSuccess("");
},
onResponse: (ctx) => {
// console.log("forgotPassword, response:", ctx.response);
console.log("forgotPassword, response:", ctx.response);
setIsPending(false);
},
onSuccess: (ctx) => {
// console.log("forgotPassword, success:", ctx.data);
console.log("forgotPassword, success:", ctx.data);
setSuccess(t("checkEmail"));
},
onError: (ctx) => {
console.log("forgotPassword, error:", ctx.error);
console.error("forgotPassword, error:", ctx.error);
setError(ctx.error.message);
},
});

View File

@ -69,7 +69,7 @@ export const LoginForm = ({ className }: { className?: string }) => {
// router.push(callbackUrl || "/dashboard");
},
onError: (ctx) => {
console.log("login, error:", ctx.error);
console.error("login, error:", ctx.error);
setError(ctx.error.message);
},
});

View File

@ -70,7 +70,7 @@ export const RegisterForm = () => {
},
onError: (ctx) => {
// sign up fail, display the error message
console.log("register, error:", ctx.error.message);
console.error("register, error:", ctx.error.message);
setError(ctx.error.message);
},
});

View File

@ -65,7 +65,7 @@ export const ResetPasswordForm = () => {
router.push(`${Routes.Login}`);
},
onError: (ctx) => {
console.log("resetPassword, error:", ctx.error);
console.error("resetPassword, error:", ctx.error);
setError(ctx.error.message);
},
});

View File

@ -17,43 +17,43 @@ import { useTransition } from "react";
* https://x.com/asidorenko_/status/1841547623712407994
*/
export default function Error({ reset }: { reset: () => void }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const t = useTranslations('ErrorPage');
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8">
<Logo className="size-12" />
const router = useRouter();
const [isPending, startTransition] = useTransition();
const t = useTranslations('ErrorPage');
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8">
<Logo className="size-12" />
<h1 className="text-2xl text-center">{t('title')}</h1>
<h1 className="text-2xl text-center">{t('title')}</h1>
<div className="flex items-center gap-4">
<Button
type="submit"
variant="default"
disabled={isPending}
onClick={() => {
startTransition(() => {
router.refresh();
reset();
});
}}
>
{isPending ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
""
)}
{t('tryAgain')}
</Button>
<div className="flex items-center gap-4">
<Button
type="submit"
variant="default"
disabled={isPending}
onClick={() => {
startTransition(() => {
router.refresh();
reset();
});
}}
>
{isPending ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
""
)}
{t('tryAgain')}
</Button>
<Button
type="submit"
variant="outline"
onClick={() => router.push("/")}
>
{t('backToHome')}
</Button>
</div>
</div>
);
<Button
type="submit"
variant="outline"
onClick={() => router.push("/")}
>
{t('backToHome')}
</Button>
</div>
</div>
);
}

27
src/i18n/messages.ts Normal file
View File

@ -0,0 +1,27 @@
import deepmerge from "deepmerge";
import { routing } from "./routing";
import type messages from "../../messages/en.json";
export type Messages = typeof messages;
export const importLocale = async (locale: string): Promise<Messages> => {
return (await import(`../../messages/${locale}.json`)).default as Messages;
};
/**
* If you have incomplete messages for a given locale and would like to use messages
* from another locale as a fallback, you can merge the two accordingly.
*
* https://next-intl.dev/docs/usage/configuration#messages
*/
export const getMessagesForLocale = async (
locale: string,
): Promise<Messages> => {
const localeMessages = await importLocale(locale);
if (locale === routing.defaultLocale) {
return localeMessages;
}
const defaultLocaleMessages = await importLocale(routing.defaultLocale);
return deepmerge(defaultLocaleMessages, localeMessages);
};

View File

@ -1,7 +1,6 @@
import { getRequestConfig } from "next-intl/server";
import { getMessagesForLocale } from "./messages";
import { routing } from "./routing";
import deepmerge from "deepmerge";
import { AbstractIntlMessages } from "next-intl";
/**
* i18n/request.ts can be used to provide configuration for server-only code,
@ -25,9 +24,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
// https://next-intl.dev/docs/usage/configuration#messages
// If you have incomplete messages for a given locale and would like to use messages
// from another locale as a fallback, you can merge the two accordingly.
const userMessages = (await import(`../../messages/${locale}.json`)).default;
const defaultMessages = (await import(`../../messages/${routing.defaultLocale}.json`)).default;
const messages = deepmerge(defaultMessages, userMessages) as AbstractIntlMessages;
const messages = await getMessagesForLocale(locale);
return {
locale,

View File

@ -7,6 +7,9 @@ export const LOCALE_LIST: Record<string, { flag: string; name: string }> = {
export const DEFAULT_LOCALE = "en";
export const LOCALES = Object.keys(LOCALE_LIST);
// The name of the cookie that is used to determine the locale
export const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
/**
* Next.js internationalized routing
*
@ -20,6 +23,11 @@ export const routing = defineRouting({
// Auto detect locale
// https://next-intl.dev/docs/routing/middleware#locale-detection
localeDetection: false,
// Once a locale is detected, it will be remembered for
// future requests by being stored in the NEXT_LOCALE cookie.
localeCookie: {
name: LOCALE_COOKIE_NAME,
},
// The prefix to use for the locale in the URL
// https://next-intl.dev/docs/routing#locale-prefix
localePrefix: "as-needed",

View File

@ -1,14 +1,22 @@
import { siteConfig } from "@/config/site";
import db from "@/db/index";
import { account, session, user, verification } from "@/db/schema";
import { Locale, LOCALE_COOKIE_NAME, routing } from "@/i18n/routing";
import { send } from "@/mail/send";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { user, session, account, verification } from "@/db/schema";
import { siteConfig } from "@/config/site";
import { resend } from "@/lib/mail";
import { admin } from "better-auth/plugins";
import db from "@/db/index";
import ResetPasswordEmail from "../../emails/reset-password";
import VerifyEmail from "../../emails/verify-email";
import { parse as parseCookies } from "cookie";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const from = process.env.RESEND_FROM || "delivered@resend.dev";
const getLocaleFromRequest = (request?: Request) => {
const cookies = parseCookies(request?.headers.get("cookie") ?? "");
return (
(cookies[LOCALE_COOKIE_NAME] as Locale) ??
routing.defaultLocale
);
};
export const auth = betterAuth({
appName: siteConfig.name,
@ -32,22 +40,27 @@ export const auth = betterAuth({
},
// https://www.better-auth.com/docs/concepts/session-management#session-expiration
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
updateAge: 60 * 60 * 24, // every 1 day the session expiration is updated
// https://www.better-auth.com/docs/concepts/session-management#session-freshness
freshAge: 60 * 5 // 5 minutes (the session is fresh if created within the last 5 minutes)
freshAge: 60 * 5 // the session is fresh if created within the last 5 minutes
},
emailAndPassword: {
enabled: true,
// https://www.better-auth.com/docs/concepts/email#2-require-email-verification
requireEmailVerification: true,
// https://www.better-auth.com/docs/authentication/email-password#forget-password
async sendResetPassword({ user, url }) {
await resend.emails.send({
from,
to: user.email,
subject: "Reset your password",
react: ResetPasswordEmail({ userName: user.name, resetLink: url }),
});
async sendResetPassword({ user, url }, request) {
const locale = getLocaleFromRequest(request);
console.log("sendResetPassword, locale:", locale);
await send({
to: user.email,
templateId: "forgotPassword",
context: {
url,
name: user.name,
},
locale,
});
},
},
emailVerification: {
@ -55,11 +68,16 @@ export const auth = betterAuth({
autoSignInAfterVerification: true,
// https://www.better-auth.com/docs/authentication/email-password#require-email-verification
sendVerificationEmail: async ( { user, url, token }, request) => {
await resend.emails.send({
from,
const locale = getLocaleFromRequest(request);
console.log("sendVerificationEmail, locale:", locale);
await send({
to: user.email,
subject: "Confirm your email address",
react: VerifyEmail({ confirmLink: url }),
templateId: "verifyEmail",
context: {
url,
name: user.name,
},
locale,
});
},
},

View File

@ -1,3 +0,0 @@
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);

View File

@ -191,17 +191,6 @@ export const RegisterSchema = z.object({
}),
});
export const ActivateSchema = z.object({
name: z.string().min(1, {
message: "GitHub username is required",
}),
license: z.string().min(1, {
message: "License key is required",
}),
});
export type ActivateFormData = z.infer<typeof ActivateSchema>;
/**
* og image schema
*/

View File

@ -0,0 +1,18 @@
import { Button } from "@react-email/components";
import React, { type PropsWithChildren } from "react";
export default function EmailButton({
href,
children,
}: PropsWithChildren<{
href: string;
}>) {
return (
<Button
href={href}
className="rounded-lg bg-black px-4 py-2 text-md text-white"
>
{children}
</Button>
);
}

View File

@ -0,0 +1,36 @@
import {
Container,
Font,
Head,
Html,
Section,
Tailwind,
} from "@react-email/components";
import React, { type PropsWithChildren } from "react";
/**
* Email Layout
*
* https://react.email/docs/components/tailwind
*/
export default function EmailLayout({ children }: PropsWithChildren) {
return (
<Html lang="en">
<Head>
<Font
fontFamily="Inter"
fallbackFontFamily="Arial"
fontWeight={400}
fontStyle="normal"
/>
</Head>
<Tailwind>
<Section className="bg-background p-4">
<Container className="rounded-lg bg-card p-6 text-card-foreground">
{children}
</Container>
</Section>
</Tailwind>
</Html>
);
}

View File

@ -0,0 +1,49 @@
import { Link, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import EmailButton from "@/mail/components/EmailButton";
import EmailLayout from "@/mail/components/EmailLayout";
import { defaultLocale, defaultTranslations } from "@/mail/translations";
import type { BaseMailProps } from "@/mail/types";
import { siteConfig } from "@/config/site";
export function ForgotPassword({
url,
name,
locale,
translations,
}: {
url: string;
name: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<EmailLayout>
<Text>{t("mail.forgotPassword.title", { name })}</Text>
<Text>{t("mail.forgotPassword.body")}</Text>
<EmailButton href={url}>
{t("mail.forgotPassword.resetPassword")}
</EmailButton>
<br /><br /><br />
<Text>{t("mail.common.team", { name: siteConfig.name })}</Text>
<Text>{t("mail.common.copyright", { year: new Date().getFullYear() })}</Text>
</EmailLayout>
);
}
ForgotPassword.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
url: "https://mksaas.com",
name: "username",
};
export default ForgotPassword;

View File

@ -0,0 +1,29 @@
import { Heading, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import EmailLayout from "@/mail/components/EmailLayout";
import { defaultLocale, defaultTranslations } from "@/mail/translations";
import type { BaseMailProps } from "@/mail/types";
export function SubscribeNewsletter({ locale, translations }: BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<EmailLayout>
<Heading className="text-xl">
{t("mail.subscribeNewsletter.subject")}
</Heading>
<Text>{t("mail.subscribeNewsletter.body")}</Text>
</EmailLayout>
);
}
SubscribeNewsletter.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
};
export default SubscribeNewsletter;

View File

@ -0,0 +1,49 @@
import { Link, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import EmailButton from "@/mail/components/EmailButton";
import EmailLayout from "@/mail/components/EmailLayout";
import { defaultLocale, defaultTranslations } from "@/mail/translations";
import type { BaseMailProps } from "@/mail/types";
import { siteConfig } from "@/config/site";
export function VerifyEmail({
url,
name,
locale,
translations,
}: {
url: string;
name: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<EmailLayout>
<Text>{t("mail.verifyEmail.title", { name })}</Text>
<Text>{t("mail.verifyEmail.body")}</Text>
<EmailButton href={url}>
{t("mail.verifyEmail.confirmEmail")}
</EmailButton>
<br /><br /><br />
<Text>{t("mail.common.team", { name: siteConfig.name })}</Text>
<Text>{t("mail.common.copyright", { year: new Date().getFullYear()})}</Text>
</EmailLayout>
);
}
VerifyEmail.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
url: "https://mksaas.com",
name: "username",
};
export default VerifyEmail;

12
src/mail/emails/index.ts Normal file
View File

@ -0,0 +1,12 @@
import { VerifyEmail } from "./VerifyEmail";
import { ForgotPassword } from "./ForgotPassword";
import { SubscribeNewsletter } from "./SubscribeNewsletter";
/**
* list all the mail templates here
*/
export const mailTemplates = {
forgotPassword: ForgotPassword,
verifyEmail: VerifyEmail,
subscribeNewsletter: SubscribeNewsletter,
} as const;

View File

@ -0,0 +1,25 @@
import { Resend } from "resend";
import { SendEmailHandler } from "@/mail/types";
export const resend = new Resend(process.env.RESEND_API_KEY);
export const sendEmail: SendEmailHandler = async ({ to, subject, html }) => {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from: process.env.RESEND_FROM,
to,
subject,
html,
}),
});
if (!response.ok) {
console.error("Error sending email", await response.json());
throw new Error("Error sending email");
}
};

64
src/mail/send.ts Normal file
View File

@ -0,0 +1,64 @@
import { Locale, routing } from "@/i18n/routing";
import type { mailTemplates } from "@/mail/emails";
import { sendEmail } from "@/mail/provider/resend";
import type { TemplateId } from "./templates";
import { getTemplate } from "./templates";
/**
* send email with given template, locale, and context
*/
export async function send<T extends TemplateId>(
params: {
to: string;
locale?: Locale;
} & (
| {
templateId: T;
context: Omit<
Parameters<(typeof mailTemplates)[T]>[0],
"locale" | "translations"
>;
}
| {
subject: string;
text?: string;
html?: string;
}
),
) {
const { to, locale = routing.defaultLocale } = params;
console.log("send, locale:", locale);
let html: string;
let text: string;
let subject: string;
if ("templateId" in params) {
const { templateId, context } = params;
const template = await getTemplate({
templateId,
context,
locale,
});
subject = template.subject;
text = template.text;
html = template.html;
} else {
subject = params.subject;
text = params.text ?? "";
html = params.html ?? "";
}
try {
await sendEmail({
to,
subject,
text,
html,
});
return true;
} catch (e) {
console.error("Error sending email", e);
return false;
}
}

41
src/mail/templates.ts Normal file
View File

@ -0,0 +1,41 @@
import type { Messages } from "@/i18n/messages";
import { getMessagesForLocale } from "@/i18n/messages";
import type { Locale } from "@/i18n/routing";
import { mailTemplates } from "@/mail/emails";
import { render } from "@react-email/render";
export type TemplateId = keyof typeof mailTemplates;
/**
* get rendered email for given template id, context, and locale
*/
export async function getTemplate<T extends TemplateId>({
templateId,
context,
locale,
}: {
templateId: T;
context: Omit<
Parameters<(typeof mailTemplates)[T]>[0],
"locale" | "translations"
>;
locale: Locale;
}) {
const template = mailTemplates[templateId];
const translations = await getMessagesForLocale(locale);
const email = template({
...(context as any),
locale,
translations,
});
const subject =
"subject" in translations.mail[templateId as keyof Messages["mail"]]
? translations.mail[templateId].subject
: "";
const html = await render(email);
const text = await render(email, { plainText: true });
return { html, text, subject };
}

7
src/mail/translations.ts Normal file
View File

@ -0,0 +1,7 @@
import { routing } from "@/i18n/routing";
const { defaultLocale } = routing;
export { default as defaultTranslations } from "../../messages/en.json";
export { defaultLocale };

19
src/mail/types.ts Normal file
View File

@ -0,0 +1,19 @@
import type { Locale } from "@/i18n/routing";
export interface SendEmailParams {
to: string;
subject: string;
text: string;
html?: string;
}
export type SendEmailHandler = (params: SendEmailParams) => Promise<void>;
export interface MailProvider {
send: SendEmailHandler;
}
export type BaseMailProps = {
locale: Locale;
translations: any;
};