From 40f85ae85c5df742f094adfc3a142999c06f98c8 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 19:33:25 +0800 Subject: [PATCH] feat(auth): implement branded HTML templates for auth emails Add a standardized HTML shell for verification and password reset emails. Update system wordings with new email copy, buttons, and fallback links. Strip standalone action links from content to use styled buttons. --- DESIGN.md | 1 + backend/src/auth.ts | 161 +++++++++++++++++++++++++++++++++++++++++--- system-wordings.ts | 30 ++++++--- 3 files changed, 176 insertions(+), 16 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cc5499e..b1de51d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -101,6 +101,7 @@ - `RESEND_API_KEY` - `EMAIL_FROM` - `APP_ORIGIN` 或 `FRONTEND_ORIGIN` +- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。 - 验证邮件包含一次性验证链接。 - 验证 token 只保存 hash,并带过期时间和使用状态。 - 只有邮箱已验证的用户可以登录。 diff --git a/backend/src/auth.ts b/backend/src/auth.ts index ec91938..9e625e0 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -62,9 +62,14 @@ type AuthMessageKey = | 'emailSubject' | 'emailHtml' | 'emailText' + | 'emailKicker' + | 'emailLinkFallback' + | 'emailFooter' + | 'verificationActionLabel' | 'passwordResetSubject' | 'passwordResetHtml' - | 'passwordResetText'; + | 'passwordResetText' + | 'passwordResetActionLabel'; type AuthTokenMessageKey = 'invalidToken' | 'invalidResetToken'; @@ -185,9 +190,14 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stripStandaloneActionLink(contentHtml: string, actionUrl: string): string { + const escapedUrl = escapeRegExp(actionUrl); + const actionLinkPattern = new RegExp( + `

\\s*]*>.*?<\\/a>\\s*<\\/p>`, + 'giu' + ); + return contentHtml.replace(actionLinkPattern, '').trim(); +} + +function authEmailHtml(options: { + subject: string; + contentHtml: string; + actionUrl: string; + actionLabel: string; + kicker: string; + linkFallback: string; + footer: string; +}): string { + const safeSubject = escapeHtml(options.subject); + const safeKicker = escapeHtml(options.kicker); + const safeActionUrl = escapeHtml(options.actionUrl); + const safeActionLabel = escapeHtml(options.actionLabel); + const safeLinkFallback = escapeHtml(options.linkFallback); + const safeFooter = escapeHtml(options.footer); + const contentHtml = stripStandaloneActionLink(options.contentHtml, options.actionUrl); + + return ` + + + + + + + + + +

${safeSubject}
+ + + + +
+ + + + + + + + + + + +
+ +`; +} + async function sendVerificationEmail(email: string, token: string, locale: string): Promise { const { apiKey, from } = getEmailConfig(); const verificationUrl = buildVerificationUrl(token); - const subject = await authMessage(locale, 'emailSubject'); - const html = await authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }); - const text = await authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours }); + const [subject, contentHtml, text, actionLabel, kicker, linkFallback, footer] = await Promise.all([ + authMessage(locale, 'emailSubject'), + authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }), + authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours }), + authMessage(locale, 'verificationActionLabel'), + authMessage(locale, 'emailKicker'), + authMessage(locale, 'emailLinkFallback'), + authMessage(locale, 'emailFooter') + ]); + const html = authEmailHtml({ + subject, + contentHtml, + actionUrl: verificationUrl, + actionLabel, + kicker, + linkFallback, + footer + }); const response = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { @@ -728,9 +858,24 @@ async function sendVerificationEmail(email: string, token: string, locale: strin async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise { const { apiKey, from } = getEmailConfig(); const resetUrl = buildPasswordResetUrl(token); - const subject = await authMessage(locale, 'passwordResetSubject'); - const html = await authMessage(locale, 'passwordResetHtml', { url: resetUrl, hours: passwordResetTokenHours }); - const text = await authMessage(locale, 'passwordResetText', { url: resetUrl, hours: passwordResetTokenHours }); + const [subject, contentHtml, text, actionLabel, kicker, linkFallback, footer] = await Promise.all([ + authMessage(locale, 'passwordResetSubject'), + authMessage(locale, 'passwordResetHtml', { url: resetUrl, hours: passwordResetTokenHours }), + authMessage(locale, 'passwordResetText', { url: resetUrl, hours: passwordResetTokenHours }), + authMessage(locale, 'passwordResetActionLabel'), + authMessage(locale, 'emailKicker'), + authMessage(locale, 'emailLinkFallback'), + authMessage(locale, 'emailFooter') + ]); + const html = authEmailHtml({ + subject, + contentHtml, + actionUrl: resetUrl, + actionLabel, + kicker, + linkFallback, + footer + }); const response = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { diff --git a/system-wordings.ts b/system-wordings.ts index ee46842..b9a309c 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -1087,14 +1087,20 @@ export const systemWordingMessages = { }, email: { auth: { + kicker: 'Account security', + linkFallback: 'If the button does not work, copy and paste this link into your browser:', + footer: 'You received this automated email because an account action was requested for Pokopia Wiki.', verificationSubject: 'Verify your Pokopia Wiki email', + verificationActionLabel: 'Verify email', verificationHtml: - '

Open the link below to verify your email:

Verify email

The link expires in {hours} hours.

', - verificationText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.', + '

Welcome to Pokopia Wiki. Confirm this email address to finish setting up your account and unlock verified editing.

Verify email

This secure link expires in {hours} hours.

', + verificationText: 'Verify your Pokopia Wiki email: {url}\nThis secure link expires in {hours} hours.', passwordResetSubject: 'Reset your Pokopia Wiki password', + passwordResetActionLabel: 'Reset password', passwordResetHtml: - '

Open the link below to reset your password:

Reset password

The link expires in {hours} hours.

', - passwordResetText: 'Open this link to reset your Pokopia Wiki password: {url}\nThe link expires in {hours} hours.' + '

Use this secure link to choose a new password for your Pokopia Wiki account.

Reset password

This link expires in {hours} hours. If you did not request this, you can ignore this email.

', + passwordResetText: + 'Reset your Pokopia Wiki password: {url}\nThis link expires in {hours} hours. If you did not request this, you can ignore this email.' } }, }, @@ -2155,12 +2161,20 @@ export const systemWordingMessages = { }, email: { auth: { + kicker: '账号安全', + linkFallback: '如果按钮无法打开,请复制以下链接到浏览器:', + footer: '这封自动邮件来自 Pokopia Wiki,用于处理你的账号操作。', verificationSubject: '验证你的 Pokopia Wiki 邮箱', - verificationHtml: '

请点击下面的链接完成邮箱验证:

验证邮箱

链接将在 {hours} 小时后失效。

', - verificationText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。', + verificationActionLabel: '验证邮箱', + verificationHtml: + '

欢迎来到 Pokopia Wiki。请确认这个邮箱地址,完成账号设置并解锁已验证编辑权限。

验证邮箱

安全链接将在 {hours} 小时后失效。

', + verificationText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n安全链接将在 {hours} 小时后失效。', passwordResetSubject: '重置你的 Pokopia Wiki 密码', - passwordResetHtml: '

请点击下面的链接重置密码:

重置密码

链接将在 {hours} 小时后失效。

', - passwordResetText: '请打开以下链接重置 Pokopia Wiki 密码:{url}\n链接将在 {hours} 小时后失效。' + passwordResetActionLabel: '重置密码', + passwordResetHtml: + '

请使用这个安全链接为你的 Pokopia Wiki 账号设置新密码。

重置密码

链接将在 {hours} 小时后失效。如果这不是你本人操作,可以忽略这封邮件。

', + passwordResetText: + '请打开以下链接重置 Pokopia Wiki 密码:{url}\n链接将在 {hours} 小时后失效。如果这不是你本人操作,可以忽略这封邮件。' } } }