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.
This commit is contained in:
2026-05-03 19:33:25 +08:00
parent 3a8a61487a
commit 40f85ae85c
3 changed files with 176 additions and 16 deletions

View File

@@ -101,6 +101,7 @@
- `RESEND_API_KEY`
- `EMAIL_FROM`
- `APP_ORIGIN``FRONTEND_ORIGIN`
- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。
- 验证邮件包含一次性验证链接。
- 验证 token 只保存 hash并带过期时间和使用状态。
- 只有邮箱已验证的用户可以登录。

View File

@@ -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<string,
emailSubject: 'email.auth.verificationSubject',
emailHtml: 'email.auth.verificationHtml',
emailText: 'email.auth.verificationText',
emailKicker: 'email.auth.kicker',
emailLinkFallback: 'email.auth.linkFallback',
emailFooter: 'email.auth.footer',
verificationActionLabel: 'email.auth.verificationActionLabel',
passwordResetSubject: 'email.auth.passwordResetSubject',
passwordResetHtml: 'email.auth.passwordResetHtml',
passwordResetText: 'email.auth.passwordResetText'
passwordResetText: 'email.auth.passwordResetText',
passwordResetActionLabel: 'email.auth.passwordResetActionLabel'
};
return systemMessage(locale || defaultLocale, messageKeys[key], params);
@@ -698,12 +708,132 @@ function buildPasswordResetUrl(token: string): string {
return buildTokenUrl('/reset-password', token);
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function stripStandaloneActionLink(contentHtml: string, actionUrl: string): string {
const escapedUrl = escapeRegExp(actionUrl);
const actionLinkPattern = new RegExp(
`<p>\\s*<a\\s+href=["']${escapedUrl}["'][^>]*>.*?<\\/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 `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<style>
@media (max-width: 620px) {
.email-shell { width: 100% !important; }
.email-card { padding: 28px 22px !important; }
.email-title { font-size: 26px !important; }
}
.email-content p { margin: 0 0 16px; }
.email-content a { color: #2a75bb; font-weight: 800; }
</style>
</head>
<body style="margin:0; padding:0; background:#f2f5fa; color:#151923; font-family:Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
<div style="display:none; max-height:0; overflow:hidden; opacity:0; color:transparent;">${safeSubject}</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse; background:#f2f5fa;">
<tr>
<td align="center" style="padding:34px 16px;">
<table class="email-shell" role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:600px; max-width:600px; border-collapse:collapse;">
<tr>
<td style="padding:0 4px 16px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
<tr>
<td align="left" style="vertical-align:middle;">
<div style="font-size:28px; line-height:1; font-weight:900; color:#ffcb05; letter-spacing:0; text-shadow:2px 3px 0 #2a75bb; -webkit-text-stroke:1px #003a70;">Pokopia</div>
<div style="margin-top:3px; font-size:12px; line-height:1.2; font-weight:800; color:#687487; text-transform:uppercase;">Wiki</div>
</td>
<td align="right" style="vertical-align:middle;">
<span style="display:inline-block; padding:7px 10px; border:1px solid #d8deea; border-radius:8px; background:#ffffff; color:#354052; font-size:12px; line-height:1.2; font-weight:800;">${safeKicker}</span>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="border:1px solid #d8deea; border-radius:8px; background:#ffffff; box-shadow:0 14px 32px rgba(23, 35, 54, .13); overflow:hidden;">
<div style="height:8px; background:#2a75bb;"></div>
<div class="email-card" style="padding:36px 34px 34px;">
<div style="display:inline-block; margin-bottom:18px; padding:8px 11px; border-radius:8px; background:#fff7cc; color:#003a70; font-size:12px; line-height:1; font-weight:900; text-transform:uppercase;">Pokopia Wiki</div>
<h1 class="email-title" style="margin:0 0 18px; color:#151923; font-size:30px; line-height:1.16; font-weight:900; letter-spacing:0;">${safeSubject}</h1>
<div class="email-content" style="color:#354052; font-size:16px; line-height:1.7;">${contentHtml}</div>
<div style="margin:28px 0 30px;">
<a href="${safeActionUrl}" style="display:inline-block; padding:14px 22px; border-radius:8px; background:#2a75bb; color:#ffffff; font-size:16px; line-height:1.2; font-weight:900; text-decoration:none; box-shadow:0 3px 0 #003a70;">${safeActionLabel}</a>
</div>
<div style="padding:16px; border:1px solid #d8deea; border-radius:8px; background:#f8fafd;">
<p style="margin:0 0 8px; color:#687487; font-size:13px; line-height:1.5; font-weight:700;">${safeLinkFallback}</p>
<a href="${safeActionUrl}" style="color:#2a75bb; font-size:13px; line-height:1.5; word-break:break-all;">${safeActionUrl}</a>
</div>
</div>
</td>
</tr>
<tr>
<td style="padding:18px 8px 0; color:#687487; font-size:12px; line-height:1.6; text-align:center;">${safeFooter}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
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<void> {
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: {

View File

@@ -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:
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
verificationText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.',
'<p>Welcome to Pokopia Wiki. Confirm this email address to finish setting up your account and unlock verified editing.</p><p><a href="{url}">Verify email</a></p><p>This secure link expires in {hours} hours.</p>',
verificationText: 'Verify your Pokopia Wiki email: {url}\nThis secure link expires in {hours} hours.',
passwordResetSubject: 'Reset your Pokopia Wiki password',
passwordResetActionLabel: 'Reset password',
passwordResetHtml:
'<p>Open the link below to reset your password:</p><p><a href="{url}">Reset password</a></p><p>The link expires in {hours} hours.</p>',
passwordResetText: 'Open this link to reset your Pokopia Wiki password: {url}\nThe link expires in {hours} hours.'
'<p>Use this secure link to choose a new password for your Pokopia Wiki account.</p><p><a href="{url}">Reset password</a></p><p>This link expires in {hours} hours. If you did not request this, you can ignore this email.</p>',
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: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
verificationText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。',
verificationActionLabel: '验证邮箱',
verificationHtml:
'<p>欢迎来到 Pokopia Wiki。请确认这个邮箱地址完成账号设置并解锁已验证编辑权限。</p><p><a href="{url}">验证邮箱</a></p><p>安全链接将在 {hours} 小时后失效。</p>',
verificationText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n安全链接将在 {hours} 小时后失效。',
passwordResetSubject: '重置你的 Pokopia Wiki 密码',
passwordResetHtml: '<p>请点击下面的链接重置密码:</p><p><a href="{url}">重置密码</a></p><p>链接将在 {hours} 小时后失效。</p>',
passwordResetText: '请打开以下链接重置 Pokopia Wiki 密码:{url}\n链接将在 {hours} 小时后失效。'
passwordResetActionLabel: '重置密码',
passwordResetHtml:
'<p>请使用这个安全链接为你的 Pokopia Wiki 账号设置新密码。</p><p><a href="{url}">重置密码</a></p><p>链接将在 {hours} 小时后失效。如果这不是你本人操作,可以忽略这封邮件。</p>',
passwordResetText:
'请打开以下链接重置 Pokopia Wiki 密码:{url}\n链接将在 {hours} 小时后失效。如果这不是你本人操作,可以忽略这封邮件。'
}
}
}