优化验证码邮件模板
Some checks are pending
Security Checks / security (push) Waiting to run

参考用户提供的卡片式 HTML 邮件样式,增加纯文本与 HTML 双格式投递,补充模板安全转义和有效期测试。
This commit is contained in:
smail 2026-10-06 11:30:45 +08:00
parent acfafc540c
commit 0d720fb878
3 changed files with 122 additions and 4 deletions

View File

@ -152,6 +152,7 @@ func deliverVerificationWithProjectMailContext(deliveryContext context.Context,
},
Subject: subject,
Text: []byte(text),
HTML: []byte(verification.CodeHTML(challenge, code)),
MsgID: messageID,
}
ctx := &appcontext.Context{

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"html"
"mime"
"net"
"net/mail"
@ -72,7 +73,7 @@ func (d *SMTPDelivery) Delivery() Delivery {
if d == nil || d.send == nil || challenge.Email == "" || code == "" {
return ErrInvalidChallenge
}
message := buildCodeMessage(d.settings.From, challenge.Email, code, challenge.Purpose)
message := buildCodeMessage(d.settings.From, challenge, code)
defer clearBytes(message)
err := d.send(d.settings, challenge.Email, message)
result := "accepted"
@ -212,9 +213,80 @@ func SendSMTPMessageContext(ctx context.Context, settings SMTPSettings, recipien
}
}
func buildCodeMessage(from, recipient string, code string, purpose Purpose) []byte {
title, text := CodeMessage(purpose, code)
return []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s\r\n", from, recipient, mime.QEncoding.Encode("UTF-8", title), text))
func buildCodeMessage(from string, challenge Challenge, code string) []byte {
title, text := CodeMessage(challenge.Purpose, code)
boundary := "=_SmailVerification_"
htmlBody := CodeHTML(challenge, code)
return []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n--%s\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s\r\n--%s\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s\r\n--%s--\r\n",
from,
challenge.Email,
mime.QEncoding.Encode("UTF-8", title),
boundary,
boundary,
text,
boundary,
htmlBody,
boundary,
))
}
// CodeHTML 返回验证码邮件的 HTML 正文。
func CodeHTML(challenge Challenge, code string) string {
recipientName := challenge.Account
if strings.TrimSpace(recipientName) == "" {
recipientName = challenge.Email
}
return buildCodeHTML(recipientName, code, verificationExpiryMinutes(challenge))
}
func verificationExpiryMinutes(challenge Challenge) int {
if challenge.ExpiresAt.After(challenge.IssuedAt) && !challenge.IssuedAt.IsZero() {
minutes := int((challenge.ExpiresAt.Sub(challenge.IssuedAt) + time.Minute - 1) / time.Minute)
if minutes > 0 {
return minutes
}
}
return int(DefaultTTL / time.Minute)
}
func buildCodeHTML(recipientName, code string, expiresInMinutes int) string {
return fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { margin: 0; padding: 24px; background: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #18181b; }
.container { max-width: 640px; margin: 0 auto; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 8px 30px rgba(15, 23, 42, 0.10); }
.header { background: #4f46e5; color: #ffffff; padding: 28px 32px; }
.header h1 { margin: 0; font-size: 24px; line-height: 1.25; }
.content { padding: 32px; font-size: 15px; line-height: 1.7; }
.code { margin: 0; font-size: 32px; font-weight: 700; letter-spacing: 8px; text-align: center; }
.muted { color: #71717a; font-size: 13px; }
.footer { padding: 18px 32px; background: #fafafa; color: #a1a1aa; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header"><h1>邮箱验证码</h1></div>
<div class="content">
<p>%s,您好:</p>
<p>您的验证码是:</p>
<p class="code">%s</p>
<p>验证码将在 <strong>%d</strong> 分钟后失效。</p>
<p class="muted">如果不是您本人操作,请忽略此邮件。</p>
</div>
<div class="footer">本邮件由 Smail 发送,请勿直接回复。</div>
</div>
</body>
</html>`,
html.EscapeString(recipientName),
html.EscapeString(code),
expiresInMinutes,
)
}
// CodeMessage 返回验证码邮件的统一标题和正文,供中继与项目自身投递复用。

View File

@ -3,6 +3,7 @@ package verification
import (
"strings"
"testing"
"time"
)
func TestSupportedPurposes(t *testing.T) {
@ -40,6 +41,50 @@ func TestSMTPDeliveryKeepsCodeOutOfChallenge(t *testing.T) {
clearBytes(message)
}
func TestSMTPDeliveryUsesReferenceHTMLTemplate(t *testing.T) {
settings := SMTPSettings{Host: "smtp.example.com", Port: 587, Security: SecurityStartTLS, From: "no-reply@example.com"}
delivery, err := NewSMTPDelivery(settings)
if err != nil {
t.Fatal(err)
}
var message []byte
delivery.send = func(_ SMTPSettings, _ string, data []byte) error {
message = append([]byte(nil), data...)
return nil
}
issuedAt := time.Date(2026, time.October, 6, 12, 0, 0, 0, time.UTC)
challenge := Challenge{
Purpose: PurposeLogin,
Account: "<xuans>",
Email: "user@example.com",
IssuedAt: issuedAt,
ExpiresAt: issuedAt.Add(7 * time.Minute),
}
if err := delivery.Delivery()(challenge, "123456"); err != nil {
t.Fatal(err)
}
raw := string(message)
for _, want := range []string{
"Content-Type: multipart/alternative;",
"Content-Type: text/plain; charset=UTF-8",
"Content-Type: text/html; charset=UTF-8",
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">",
"<h1>邮箱验证码</h1>",
"<p>&lt;xuans&gt;,您好:</p>",
"<p class=\"code\">123456</p>",
"<strong>7</strong> 分钟",
"本邮件由 Smail 发送",
} {
if !strings.Contains(raw, want) {
t.Fatalf("message does not contain %q: %s", want, raw)
}
}
if strings.Contains(raw, "<p><xuans>") {
t.Fatal("recipient name was not HTML escaped")
}
clearBytes(message)
}
func TestCodeMessageDescribesExternalEmailBinding(t *testing.T) {
title, text := CodeMessage(PurposeExternalEmailBind, "12345678")
if title != "Smail 安全验证码" {