refactor: refactor mail module & enhance auth email with locale support

- Added locale handling to email sending functions, allowing for localized URLs in reset password and verification emails.
- Introduced a new utility function `addLocaleToUrl` to append locale to callback URLs in authentication flows.
- Refactored the email sending process to utilize a mail provider interface, supporting both templated and raw email sending.
- Created a comprehensive README for the email system, detailing usage, configuration, and available templates.
- Established a default mail configuration for improved email management.
This commit is contained in:
javayhu 2025-03-19 01:00:14 +08:00
parent 6744c52087
commit 130338b9a5
9 changed files with 525 additions and 108 deletions

View File

@ -41,6 +41,7 @@ export const contactAction = actionClient
// Send email using the mail service
// Customize the email template for your needs
// TODO: add locale to the email or customize it by yourself?
const result = await send({
to: websiteConfig.mail.to,
subject: `Contact Form: Message from ${name}`,

View File

@ -1,7 +1,7 @@
import db from '@/db/index';
import { account, session, user, verification } from '@/db/schema';
import { defaultMessages } from '@/i18n/messages';
import { getLocaleFromRequest } from '@/lib/utils';
import { getLocaleFromRequest, addLocaleToUrl } from '@/lib/utils';
import { send } from '@/mail';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
@ -48,12 +48,17 @@ export const auth = betterAuth({
// https://www.better-auth.com/docs/authentication/email-password#forget-password
async sendResetPassword({ user, url }, request) {
const locale = getLocaleFromRequest(request);
// TODO: add locale to url
// console.log('[Auth] Reset password original URL:', url);
// Add locale to URL if necessary
const localizedUrl = addLocaleToUrl(url, locale);
// console.log('[Auth] Reset password localized URL:', localizedUrl);
await send({
to: user.email,
template: 'forgotPassword',
context: {
url,
url: localizedUrl,
name: user.name,
},
locale,
@ -66,12 +71,17 @@ export const auth = betterAuth({
// https://www.better-auth.com/docs/authentication/email-password#require-email-verification
sendVerificationEmail: async ({ user, url, token }, request) => {
const locale = getLocaleFromRequest(request);
// TODO: add locale to url
// console.log('[Auth] Verification email original URL:', url);
// Add locale to URL if necessary
const localizedUrl = addLocaleToUrl(url, locale);
// console.log('[Auth] Verification email localized URL:', localizedUrl);
await send({
to: user.email,
template: 'verifyEmail',
context: {
url,
url: localizedUrl,
name: user.name,
},
locale,

View File

@ -1,4 +1,5 @@
import { LOCALE_COOKIE_NAME, routing } from '@/i18n/routing';
import { shouldAppendLocale } from '@/lib/urls/get-base-url';
import { type ClassValue, clsx } from 'clsx';
import { parse as parseCookies } from 'cookie';
import { Locale } from 'next-intl';
@ -75,3 +76,51 @@ export function estimateReadingTime(
const minutes = Math.ceil(words / wordsPerMinute);
return minutes === 1 ? '1 minute read' : `${minutes} minutes read`;
}
/**
* Adds locale to the callbackURL parameter in authentication URLs
*
* Example:
* Input: http://localhost:3000/api/auth/reset-password/token?callbackURL=/auth/reset-password
* Output: http://localhost:3000/api/auth/reset-password/token?callbackURL=/zh/auth/reset-password
*
* http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/dashboard
* Output: http://localhost:3000/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9&callbackURL=/zh/dashboard
*
* @param url - The original URL with callbackURL parameter
* @param locale - The locale to add to the callbackURL
* @returns The URL with locale added to callbackURL if necessary
*/
export function addLocaleToUrl(url: string, locale: Locale): string {
// If we shouldn't append locale, return original URL
if (!shouldAppendLocale(locale)) {
return url;
}
try {
// Parse the URL
const urlObj = new URL(url);
// Check if there's a callbackURL parameter
const callbackURL = urlObj.searchParams.get('callbackURL');
if (callbackURL) {
// Only modify the callbackURL if it doesn't already include the locale
if (!callbackURL.match(new RegExp(`^/${locale}(/|$)`))) {
// Add locale to the callbackURL
const localizedCallbackURL = callbackURL.startsWith('/')
? `/${locale}${callbackURL}`
: `/${locale}/${callbackURL}`;
// Update the search parameter
urlObj.searchParams.set('callbackURL', localizedCallbackURL);
}
}
return urlObj.toString();
} catch (e) {
// If URL parsing fails, return the original URL
console.warn('Failed to parse URL for locale insertion:', url, e);
return url;
}
}

183
src/mail/README.md Normal file
View File

@ -0,0 +1,183 @@
# Email System
This module provides email functionality for the application. It supports sending emails using templates or raw content through different email providers.
## Structure
The email system is designed with the following components:
- **Provider Interface**: A common interface for email providers
- **Email Templates**: React-based email templates for different purposes
- **Configuration**: Configuration for email defaults and settings
## Usage
### Basic Usage
```typescript
import { send } from '@/mail';
// Send using a template
await send({
to: 'user@example.com',
template: 'verifyEmail',
context: {
name: 'John Doe',
url: 'https://example.com/verify?token=abc123',
},
locale: 'en', // Optional, defaults to config default locale
});
// Send a raw email
await send({
to: 'user@example.com',
subject: 'Welcome to our platform',
html: '<h1>Hello!</h1><p>Welcome to our platform.</p>',
text: 'Hello! Welcome to our platform.', // Optional
});
```
### Using the Mail Provider Directly
```typescript
import { getMailProvider, sendTemplate, sendRawEmail } from '@/mail';
// Get the provider
const provider = getMailProvider();
// Send template email
const result = await sendTemplate({
to: 'user@example.com',
template: 'welcomeEmail',
context: {
name: 'John Doe',
},
});
// Check result
if (result.success) {
console.log('Email sent successfully!', result.messageId);
} else {
console.error('Failed to send email:', result.error);
}
// Send raw email
await sendRawEmail({
to: 'user@example.com',
subject: 'Raw email example',
html: '<p>This is a raw email</p>',
});
```
## Email Templates
Email templates are React components stored in the `emails` directory. Each template has specific props and is rendered to HTML/text when sent.
### Available Templates
- `verifyEmail`: For email verification
- `forgotPassword`: For password reset
- `subscribeNewsletter`: For new user subscribed
### Creating a New Template
1. Create a React component in the `emails` directory
2. Make sure it accepts `BaseEmailProps` plus any specific props
3. Add it to the `EmailTemplates` export in `emails/index.ts`
4. Add corresponding subject translations in the i18n messages
Example:
```tsx
// emails/MyNewEmail.tsx
import { BaseEmailProps } from '@/mail/types';
import { Body, Container, Head, Html, Text } from '@react-email/components';
interface MyNewEmailProps extends BaseEmailProps {
username: string;
}
export default function MyNewEmail({ username, messages, locale }: MyNewEmailProps) {
return (
<Html lang={locale}>
<Head />
<Body>
<Container>
<Text>Hello {username}!</Text>
</Container>
</Body>
</Html>
);
}
```
Then add it to `emails/index.ts`:
```typescript
import MyNewEmail from './MyNewEmail';
export const EmailTemplates = {
// ... existing templates
myNewEmail: MyNewEmail,
};
```
## Configuration
The email system configuration is defined in `config/mail-config.ts`. It includes settings like:
- Default "from" email address
- Default locale for emails
## Providers
### Resend
[Resend](https://resend.com/) is the default email provider. It requires an API key set as `RESEND_API_KEY` in your environment variables.
### Adding a New Provider
To add a new email provider:
1. Create a new file in the `provider` directory
2. Implement the `MailProvider` interface
3. Update the `initializeMailProvider` function in `index.ts` to use your new provider
Example:
```typescript
// provider/my-provider.ts
import { MailProvider, SendEmailResult, SendRawEmailParams, SendTemplateParams } from '@/mail/types';
export class MyProvider implements MailProvider {
constructor() {
// Initialize your provider
}
public async sendTemplate(params: SendTemplateParams): Promise<SendEmailResult> {
// Implementation
}
public async sendRawEmail(params: SendRawEmailParams): Promise<SendEmailResult> {
// Implementation
}
public getProviderName(): string {
return 'my-provider';
}
}
```
Then update `index.ts`:
```typescript
import { MyProvider } from './provider/my-provider';
export const initializeMailProvider = (): MailProvider => {
if (!mailProvider) {
// Select provider based on configuration or environment
mailProvider = new MyProvider();
}
return mailProvider;
};
```

View File

@ -0,0 +1,11 @@
import { websiteConfig } from '@/config';
import { routing } from '@/i18n/routing';
import { MailConfig } from '@/mail/types';
/**
* Default mail configuration
*/
export const mailConfig: MailConfig = {
defaultFromEmail: websiteConfig.mail.from || 'noreply@example.com',
defaultLocale: routing.defaultLocale,
};

View File

@ -1,5 +1,74 @@
// Export the send function for direct import
export { send } from './mail';
import { MailProvider, MailConfig, SendTemplateParams, SendRawEmailParams, SendEmailResult, Template } from './types';
import { ResendProvider } from './provider/resend';
import { mailConfig } from './config/mail-config';
// Export mail templates
/**
* Default mail configuration
*/
export const defaultMailConfig: MailConfig = mailConfig;
/**
* Global mail provider instance
*/
let mailProvider: MailProvider | null = null;
/**
* Initialize the mail provider
* @returns initialized mail provider
*/
export const initializeMailProvider = (): MailProvider => {
if (!mailProvider) {
mailProvider = new ResendProvider();
}
return mailProvider;
};
/**
* Get the mail provider
* @returns current mail provider instance
* @throws Error if provider is not initialized
*/
export const getMailProvider = (): MailProvider => {
if (!mailProvider) {
return initializeMailProvider();
}
return mailProvider;
};
/**
* Send an email using a template
* @param params Parameters for sending the templated email
* @returns Send result
*/
export const sendTemplate = async (params: SendTemplateParams):
Promise<SendEmailResult> => {
const provider = getMailProvider();
return provider.sendTemplate(params);
};
/**
* Send a raw email
* @param params Parameters for sending the raw email
* @returns Send result
*/
export const sendRawEmail = async (params: SendRawEmailParams):
Promise<SendEmailResult> => {
const provider = getMailProvider();
return provider.sendRawEmail(params);
};
// Export from mail.ts
export { send, getTemplate } from './mail';
// Export email templates
export { EmailTemplates } from './emails';
// Export types for convenience
export type {
MailProvider,
MailConfig,
SendTemplateParams,
SendRawEmailParams,
SendEmailResult,
Template,
};

View File

@ -1,89 +1,44 @@
import { getMessagesForLocale } from '@/i18n/messages';
import { routing } from '@/i18n/routing';
import { getMailProvider } from '@/mail';
import { EmailTemplates } from '@/mail/emails';
import { sendEmail } from '@/mail/provider/resend';
import { SendRawEmailParams, SendTemplateParams, Template } from '@/mail/types';
import { render } from '@react-email/render';
import { Locale, Messages } from 'next-intl';
import { Template } from '@/mail/types';
/**
* send email
*
* 1. with given template, and context
* 2. with given subject, text, and html
* Send email using the configured mail provider
*
* @param params Email parameters
* @returns Success status
*/
export async function send<T extends Template>(
params: {
to: string;
locale?: Locale;
} & (
| {
template: T;
context: Omit<
Parameters<(typeof EmailTemplates)[T]>[0],
'locale' | 'messages'
>;
}
| {
subject: string;
text?: string;
html?: string;
}
)
export async function send(
params: SendTemplateParams | SendRawEmailParams
) {
const { to, locale = routing.defaultLocale } = params;
console.log('send, locale:', locale);
const provider = getMailProvider();
let html: string;
let text: string;
let subject: string;
// if template is provided, get the template
// otherwise, use the subject, text, and html
if ('template' in params) {
const { template, context } = params;
const mailTemplate = await getTemplate({
template,
context,
locale,
});
subject = mailTemplate.subject;
text = mailTemplate.text;
html = mailTemplate.html;
// This is a template email
const result = await provider.sendTemplate(params);
return result.success;
} 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;
// This is a raw email
const result = await provider.sendRawEmail(params);
return result.success;
}
}
/**
* get rendered email for given template, context, and locale
* Get rendered email for given template, context, and locale
*/
async function getTemplate<T extends Template>({
export async function getTemplate<T extends Template>({
template,
context,
locale,
locale = routing.defaultLocale,
}: {
template: T;
context: Omit<
Parameters<(typeof EmailTemplates)[T]>[0],
'locale' | 'messages'
>;
locale: Locale;
context: Record<string, any>;
locale?: Locale;
}) {
const mainTemplate = EmailTemplates[template];
const messages = await getMessagesForLocale(locale);
@ -94,7 +49,7 @@ async function getTemplate<T extends Template>({
messages,
});
// get the subject from the messages
// Get the subject from the messages
const subject =
'subject' in messages.Mail[template as keyof Messages['Mail']]
? messages.Mail[template].subject
@ -102,5 +57,6 @@ async function getTemplate<T extends Template>({
const html = await render(email);
const text = await render(email, { plainText: true });
return { html, text, subject };
}

View File

@ -1,36 +1,115 @@
import { websiteConfig } from '@/config';
import { SendEmailHandler } from '@/mail/types';
import { MailProvider, SendEmailResult, SendRawEmailParams, SendTemplateParams } from '@/mail/types';
import { getTemplate } from '@/mail/mail';
import { Resend } from 'resend';
const apiKey = process.env.RESEND_API_KEY || 'test_api_key';
const resend = new Resend(apiKey);
/**
* https://resend.com/docs/send-with-nextjs
* Resend mail provider implementation
*/
export const sendEmail: SendEmailHandler = async ({ to, subject, html }) => {
if (!process.env.RESEND_API_KEY) {
console.warn('RESEND_API_KEY not set, skipping email send');
return false;
export class ResendProvider implements MailProvider {
private resend: Resend;
private from: string;
/**
* Initialize Resend provider with API key
*/
constructor() {
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY environment variable is not set.');
}
if (!websiteConfig.mail.from) {
throw new Error('Default from email address is not set in websiteConfig.');
}
const apiKey = process.env.RESEND_API_KEY;
this.resend = new Resend(apiKey);
this.from = websiteConfig.mail.from;
}
if (!websiteConfig.mail.from || !to || !subject || !html) {
console.warn('Missing required fields for email send', { from: websiteConfig.mail.from, to, subject, html });
return false;
/**
* Send an email using a template
* @param params Parameters for sending a templated email
* @returns Send result
*/
public async sendTemplate(params: SendTemplateParams): Promise<SendEmailResult> {
const { to, template, context, locale } = params;
try {
// Get rendered template
const mailTemplate = await getTemplate({
template,
context,
locale,
});
// Send using raw email
return this.sendRawEmail({
to,
subject: mailTemplate.subject,
html: mailTemplate.html,
text: mailTemplate.text,
});
} catch (error) {
console.error('Error sending template email:', error);
return {
success: false,
error,
};
}
}
const { data, error } = await resend.emails.send({
from: websiteConfig.mail.from,
to,
subject,
html,
});
/**
* Send a raw email
* @param params Parameters for sending a raw email
* @returns Send result
*/
public async sendRawEmail(params: SendRawEmailParams): Promise<SendEmailResult> {
const { to, subject, html, text } = params;
if (error) {
console.error('Error sending email', error);
return false;
if (!this.from || !to || !subject || !html) {
console.warn('Missing required fields for email send', { from: this.from, to, subject, html });
return {
success: false,
error: 'Missing required fields',
};
}
try {
const { data, error } = await this.resend.emails.send({
from: this.from,
to,
subject,
html,
text,
});
if (error) {
console.error('Error sending email', error);
return {
success: false,
error,
};
}
return {
success: true,
messageId: data?.id,
};
} catch (error) {
console.error('Error sending email:', error);
return {
success: false,
error,
};
}
}
return true;
};
/**
* Get the provider name
* @returns Provider name
*/
public getProviderName(): string {
return 'resend';
}
}

View File

@ -1,25 +1,84 @@
import { Locale, Messages } from 'next-intl';
import { EmailTemplates } from './emails';
/**
* Base email component props
*/
export interface BaseEmailProps {
locale: Locale;
messages: Messages;
}
export interface SendEmailProps {
/**
* Common email sending parameters
*/
export interface SendEmailParams {
to: string;
subject: string;
text: string;
html?: string;
text?: string;
html: string;
from?: string;
}
export type Template = keyof typeof EmailTemplates;
export type SendEmailHandler = (params: SendEmailProps) => Promise<boolean>;
/**
* Email provider, currently only Resend is supported
* Result of sending an email
*/
export interface EmailProvider {
send: SendEmailHandler;
export interface SendEmailResult {
success: boolean;
messageId?: string;
error?: any;
}
/**
* Email template types
*/
export type Template = keyof typeof EmailTemplates;
/**
* Parameters for sending an email using a template
*/
export interface SendTemplateParams {
to: string;
template: Template;
context: Record<string, any>;
locale?: Locale;
}
/**
* Parameters for sending a raw email
*/
export interface SendRawEmailParams {
to: string;
subject: string;
html: string;
text?: string;
locale?: Locale;
}
/**
* Mail provider configuration
*/
export interface MailConfig {
defaultFromEmail: string;
defaultLocale: Locale;
}
/**
* Mail provider interface
*/
export interface MailProvider {
/**
* Send an email using a template
*/
sendTemplate(params: SendTemplateParams): Promise<SendEmailResult>;
/**
* Send a raw email
*/
sendRawEmail(params: SendRawEmailParams): Promise<SendEmailResult>;
/**
* Get the provider's name
*/
getProviderName(): string;
}