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:
parent
8aaead0245
commit
95ddd0a1dc
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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": "感谢您的订阅"
|
||||
}
|
||||
}
|
||||
}
|
@ -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
15
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
@ -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
27
src/i18n/messages.ts
Normal 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);
|
||||
};
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
@ -1,3 +0,0 @@
|
||||
import { Resend } from "resend";
|
||||
|
||||
export const resend = new Resend(process.env.RESEND_API_KEY);
|
@ -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
|
||||
*/
|
||||
|
18
src/mail/components/EmailButton.tsx
Normal file
18
src/mail/components/EmailButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
src/mail/components/EmailLayout.tsx
Normal file
36
src/mail/components/EmailLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
49
src/mail/emails/ForgotPassword.tsx
Normal file
49
src/mail/emails/ForgotPassword.tsx
Normal 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;
|
29
src/mail/emails/SubscribeNewsletter.tsx
Normal file
29
src/mail/emails/SubscribeNewsletter.tsx
Normal 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;
|
49
src/mail/emails/VerifyEmail.tsx
Normal file
49
src/mail/emails/VerifyEmail.tsx
Normal 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
12
src/mail/emails/index.ts
Normal 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;
|
25
src/mail/provider/resend.ts
Normal file
25
src/mail/provider/resend.ts
Normal 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
64
src/mail/send.ts
Normal 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
41
src/mail/templates.ts
Normal 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
7
src/mail/translations.ts
Normal 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
19
src/mail/types.ts
Normal 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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user