I had use Resend for a long time. Simple to use and free send limit. But resend can not config your sender email if you are not pro plan.
Googl SMTP:
You can use other SMTP provider, But i think gmail is a good choice.
Create your google workspace:
You can find many articles and docs to config this, i will not teach you config it.
finally you should get 5 env:
SMTP_HOST=smtp.gmail.com SMTP_USER=**************(smtp user in google workspace) SMTP_PASS=**************(smtp password in google workspace) SMTP_PORT=587 FROM_EMAIL=**************(from email)
Config DNS Record:
Create Email Lib:
I prefer you create email folder in your lib folder, because email is a third part of your project, you can change other provider In your future.
Install nodemailer:
bun add nodemailer
Index.ts:
create your email index.ts tools function:
import nodemailer from 'nodemailer' // 邮件配置接口 export interface EmailConfig { host: string port: number secure: boolean auth: { user: string pass: string } } // 邮件内容接口 export interface EmailContent { to: string | string[] subject: string from?: string html?: string text?: string attachments?: Array<{ filename: string content: string | Buffer contentType?: string }> } // 创建邮件传输器 export function createTransporter(config: EmailConfig) { return nodemailer.createTransport(config) } // 创建 Gmail 传输器(推荐使用应用专用密码) export function createGmailTransporter(email: string, appPassword: string) { return createTransporter({ host: process.env.SMTP_HOST || 'smtp.gmail.com', port: 587, secure: false, // 使用 STARTTLS auth: { user: email, pass: appPassword, // 使用应用专用密码,不是普通密码 }, }) } // 发送邮件 export async function sendEmail( transporter: nodemailer.Transporter, content: EmailContent, from?: string ): Promise<boolean> { try { const mailOptions = { from: from || content.from, ...content, } const info = await transporter.sendMail(mailOptions) console.log('邮件发送成功:', info.messageId) return true } catch (error) { console.error('邮件发送失败:', error) return false } } // 发送简单文本邮件 export async function sendTextEmail( transporter: nodemailer.Transporter, to: string | string[], subject: string, text: string ): Promise<boolean> { return sendEmail(transporter, { to, subject, text, }) } // 发送 HTML 邮件 export async function sendHtmlEmail( transporter: nodemailer.Transporter, to: string | string[], subject: string, html: string ): Promise<boolean> { return sendEmail(transporter, { to, subject, html, }) } // 发送带附件的邮件 export async function sendEmailWithAttachments( transporter: nodemailer.Transporter, to: string | string[], subject: string, html: string, attachments: Array<{ filename: string content: string | Buffer contentType?: string }> ): Promise<boolean> { return sendEmail(transporter, { to, subject, html, attachments, }) } // 验证邮件配置 export async function verifyEmailConfig( transporter: nodemailer.Transporter ): Promise<boolean> { try { await transporter.verify() console.log('邮件配置验证成功') return true } catch (error) { console.error('邮件配置验证失败:', error) return false } } // 默认邮件发送函数(使用环境变量) export async function sendEmailWithDefaults( to: string | string[], subject: string, html: string, text?: string ): Promise<boolean> { const email = process.env.SMTP_USER const appPassword = process.env.SMTP_PASS if (!email || !appPassword) { console.error('缺少 Gmail 配置环境变量') return false } const transporter = createGmailTransporter(email, appPassword) // 验证配置 const isValid = await verifyEmailConfig(transporter) if (!isValid) { return false } return sendEmail(transporter, { to, subject, html, text, }) }
Email HTML template:
If you want your user get an email with a good look, you should create your own HTML template instead of plain text.
Here is a simple example:
dark mode attention!
/** * 密码重置邮件模板 */ export function getPasswordResetTemplate( email: string, resetUrl: string, expiresIn: string = '1 hour' ): string { return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Reset your password</title> <style> @media (prefers-color-scheme: dark) { .security-note-text { color: #ffffff !important; } .security-note-title { color: #ffffff !important; } .security-note-content { color: #f3f4f6 !important; } } @media (prefers-color-scheme: light) { .security-note-text { color: #92400e !important; } .security-note-title { color: #92400e !important; } .security-note-content { color: #92400e !important; } } </style> </head> <body style="margin: 0; padding: 0; background-color: #f3f4f6;"> <div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);"> <!-- Header --> <div style="text-align: center; margin-bottom: 40px; padding: 30px 0; border-bottom: 1px solid #e5e7eb;"> <h1 style="color: #1f2937; font-size: 32px; font-weight: 700; margin: 0 0 12px 0; letter-spacing: -0.025em;">Reset your password</h1> <div style="width: 80px; height: 4px; background: linear-gradient(90deg, #ef4444, #f59e0b); margin: 0 auto; border-radius: 2px;"></div> </div> <!-- Main Content --> <div style="background-color: #fef2f2; border-radius: 12px; padding: 40px; margin-bottom: 30px; border: 1px solid #fecaca;"> <div style="text-align: center; margin-bottom: 30px;"> <div style="width: 80px; height: 80px; background: linear-gradient(135deg, #ef4444, #f59e0b); border-radius: 50%; margin: 0 auto 20px; display: flex; align-items: center; justify-content: center;"> <span style="color: white; font-size: 32px;">🔒</span> </div> </div> <p style="color: #374151; font-size: 18px; line-height: 1.6; margin: 0 0 24px 0; text-align: center;"> We received a request to reset the password for <strong style="color: #1f2937; background-color: #fef3c7; padding: 2px 8px; border-radius: 4px;">${email}</strong>. </p> <p style="color: #6b7280; font-size: 16px; line-height: 1.6; margin: 0 0 30px 0; text-align: center;"> Click the button below to reset your password: </p> <!-- CTA Button --> <div style="text-align: center; margin: 40px 0;"> <a href="${resetUrl}" style="display: inline-block; background: linear-gradient(135deg, #ef4444, #f59e0b); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 10px; font-weight: 600; font-size: 18px; box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); transition: all 0.2s ease; letter-spacing: 0.025em;"> Reset Password </a> </div> <!-- Security Note --> <div style="background: linear-gradient(135deg, #fef3c7, #fde68a); border: 1px solid #f59e0b; border-radius: 10px; padding: 20px; margin-top: 30px;"> <div style="display: flex; align-items: flex-start; gap: 12px;"> <div style="width: 24px; height: 24px; background-color: #f59e0b; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 2px;"> <span style="color: white; font-size: 14px; font-weight: bold;">!</span> </div> <div> <p class="security-note-title" style="color: #92400e; font-size: 15px; margin: 0 0 8px 0; font-weight: 600;"> Security Note </p> <p class="security-note-content" style="color: #92400e; font-size: 14px; margin: 0; line-height: 1.5;"> This password reset link will expire in <strong>${expiresIn}</strong>. If you didn't request this password reset, please ignore this email and your password will remain unchanged. </p> </div> </div> </div> </div> <!-- Footer --> <div style="text-align: center; padding: 20px 0; border-top: 1px solid #e5e7eb;"> <p style="color: #6b7280; font-size: 14px; margin: 0 0 16px 0;"> If the button doesn't work, copy and paste this link into your browser: </p> <div style="background-color: #f3f4f6; border-radius: 8px; padding: 12px; margin: 0 0 20px 0;"> <p style="color: #3b82f6; font-size: 13px; word-break: break-all; margin: 0; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;"> ${resetUrl} </p> </div> <p style="color: #9ca3af; font-size: 12px; margin: 0;"> This email was sent from a notification-only address. Please do not reply to this email. </p> </div> </div> </body> </html> `
Send Email:
finally, you can send your email:
// 示例 6: 发送密码重置邮件 ✅ export async function sendPasswordResetEmail(email: string) { try { const smtpEmail = process.env.SMTP_USER || process.env.GMAIL_USER || '' const smtpPassword = process.env.SMTP_PASS || process.env.GMAIL_APP_PASSWORD || '' if (!smtpEmail || !smtpPassword) { throw new Error('Email configuration missing') } // 使用 BetterAuth 的 requestPasswordReset API const response = await fetch( `${process.env.BETTER_AUTH_URL || 'http://localhost:3000'}/api/auth/request-password-reset`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ email: email, redirectTo: `${process.env.NEXT_PUBLIC_APP_URL}/reset`, }), } ) if (!response.ok) { throw new Error('Failed to request password reset') } const result = await response.json() return result } catch (error) { console.error('Failed to send password reset email:', error) throw error } }
pic: