From 0d720fb878a918da4e2b3ed8990522c5c790ed7f Mon Sep 17 00:00:00 2001 From: smail Date: Tue, 6 Oct 2026 11:30:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考用户提供的卡片式 HTML 邮件样式,增加纯文本与 HTML 双格式投递,补充模板安全转义和有效期测试。 --- backend/res_init/verification_delivery.go | 1 + backend/services/verification/delivery.go | 80 ++++++++++++++++++- .../services/verification/delivery_test.go | 45 +++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index 941aa5e..581a986 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -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{ diff --git a/backend/services/verification/delivery.go b/backend/services/verification/delivery.go index 8d01f35..63a2c43 100644 --- a/backend/services/verification/delivery.go +++ b/backend/services/verification/delivery.go @@ -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(` + + + + + + + + +
+

邮箱验证码

+
+

%s,您好:

+

您的验证码是:

+

%s

+

验证码将在 %d 分钟后失效。

+

如果不是您本人操作,请忽略此邮件。

+
+ +
+ +`, + html.EscapeString(recipientName), + html.EscapeString(code), + expiresInMinutes, + ) } // CodeMessage 返回验证码邮件的统一标题和正文,供中继与项目自身投递复用。 diff --git a/backend/services/verification/delivery_test.go b/backend/services/verification/delivery_test.go index fb4e211..58faaf3 100644 --- a/backend/services/verification/delivery_test.go +++ b/backend/services/verification/delivery_test.go @@ -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: "", + 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", + "", + "

邮箱验证码

", + "

<xuans>,您好:

", + "

123456

", + "7 分钟", + "本邮件由 Smail 发送", + } { + if !strings.Contains(raw, want) { + t.Fatalf("message does not contain %q: %s", want, raw) + } + } + if strings.Contains(raw, "

") { + t.Fatal("recipient name was not HTML escaped") + } + clearBytes(message) +} + func TestCodeMessageDescribesExternalEmailBinding(t *testing.T) { title, text := CodeMessage(PurposeExternalEmailBind, "12345678") if title != "Smail 安全验证码" {