diff --git a/backend/hooks/wechat_push/verification_code.go b/backend/hooks/wechat_push/verification_code.go index dcadd4b..3ea3229 100644 --- a/backend/hooks/wechat_push/verification_code.go +++ b/backend/hooks/wechat_push/verification_code.go @@ -4,6 +4,7 @@ import ( "bytes" "regexp" "strings" + "unicode" "git.pcux.cn/xuans/Smail/dto/parsemail" "golang.org/x/net/html" @@ -23,17 +24,27 @@ func extractWeChatVerificationCode(email *parsemail.Email) (string, bool) { return "", false } // 分别检查标题和各正文,避免跨字段拼接出不存在的验证码。 - if code, ok := findWeChatVerificationCode(truncateWeChatText(email.Subject, maxWeChatVerificationScanBytes)); ok { + if code, ok := findWeChatVerificationCode(boundedWeChatVerificationText(email.Subject)); ok { return code, true } text := email.Text[:min(len(email.Text), maxWeChatVerificationScanBytes)] - if code, ok := findWeChatVerificationCode(string(text)); ok { + plain := string(text) + if len(email.Text) > len(text) { + plain += "_" + } + if code, ok := findWeChatVerificationCode(plain); ok { return code, true } return findWeChatVerificationCode(weChatHTMLNotificationText(email.HTML)) } func findWeChatVerificationCode(content string) (string, bool) { + content = strings.Map(func(char rune) rune { + if unicode.IsSpace(char) { + return ' ' + } + return char + }, content) for _, pattern := range weChatVerificationPatterns { for _, match := range pattern.FindAllStringSubmatch(content, -1) { code := match[1] @@ -46,8 +57,17 @@ func findWeChatVerificationCode(content string) (string, bool) { return "", false } +func boundedWeChatVerificationText(content string) string { + if len(content) > maxWeChatVerificationScanBytes { + // 用标识符边界阻止截断的长串被误识别为完整验证码,不进入通知正文。 + return truncateWeChatText(content, maxWeChatVerificationScanBytes) + "_" + } + return content +} + func weChatHTMLNotificationText(content []byte) string { // 限制原始输入,避免大邮件在通知提取阶段产生无界扫描和额外分配。 + truncated := len(content) > maxWeChatVerificationScanBytes content = content[:min(len(content), maxWeChatVerificationScanBytes)] tokenizer := html.NewTokenizer(bytes.NewReader(content)) var text strings.Builder @@ -55,8 +75,11 @@ func weChatHTMLNotificationText(content []byte) string { for { switch tokenizer.Next() { case html.ErrorToken: + if truncated { + text.WriteByte('_') + } return text.String() - case html.StartTagToken: + case html.StartTagToken, html.SelfClosingTagToken: name, _ := tokenizer.TagName() if weChatHiddenHTMLTag(string(name)) { hiddenDepth++ @@ -69,8 +92,8 @@ func weChatHTMLNotificationText(content []byte) string { case html.TextToken: if hiddenDepth == 0 { // 分词器负责解码实体;不读取属性、注释、脚本及样式中的内容。 - text.Write(tokenizer.Text()) text.WriteByte(' ') + text.Write(tokenizer.Text()) } } } diff --git a/backend/hooks/wechat_push/wechat_push_test.go b/backend/hooks/wechat_push/wechat_push_test.go index 78b47cf..034e90d 100644 --- a/backend/hooks/wechat_push/wechat_push_test.go +++ b/backend/hooks/wechat_push/wechat_push_test.go @@ -113,6 +113,115 @@ func TestWeChatRuntimeConfigRejectsTrailingJSON(t *testing.T) { } } +func TestFindWeChatVerificationCode(t *testing.T) { + tests := []struct { + name string + content string + want string + }{ + {name: "chinese", content: "您的验证码为:123456,请勿泄露", want: "123456"}, + {name: "before keyword", content: "847291 是您的验证码", want: "847291"}, + {name: "english", content: "Your verification code is A7B9C2", want: "A7B9C2"}, + {name: "leading zero", content: "验证码:001234", want: "001234"}, + {name: "four characters", content: "校验码:a1B2", want: "a1B2"}, + {name: "eight characters", content: "动态码:a1B2c3D4", want: "a1B2c3D4"}, + {name: "one time password", content: "一次性密码:123456", want: "123456"}, + {name: "english security", content: "SECURITY CODE: a1B2c3", want: "a1B2c3"}, + {name: "english before", content: "123456 is your verification code", want: "123456"}, + {name: "otp", content: "OTP: 123456", want: "123456"}, + {name: "nonbreaking space", content: "verification code:\u00a0123456", want: "123456"}, + {name: "three characters", content: "验证码:123"}, + {name: "nine characters", content: "验证码:123456789"}, + {name: "letters only", content: "verification code: secure"}, + {name: "no keyword", content: "订单号 123456"}, + {name: "year", content: "2026 年验证码安全规则更新"}, + {name: "identifier suffix", content: "验证码:123456_value"}, + {name: "identifier prefix", content: "验证码:value_123456"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := findWeChatVerificationCode(tt.content) + if got != tt.want || ok != (tt.want != "") { + t.Fatalf("验证码 = %q,匹配 = %v,期望 %q", got, ok, tt.want) + } + }) + } +} + +func TestBuildWeChatMessageVerificationContent(t *testing.T) { + tests := []struct { + name string + email *parsemail.Email + want string + }{ + { + name: "html entities", + email: &parsemail.Email{HTML: []byte("

验证码: 654321

")}, + want: "验证码:654321", + }, + { + name: "subject priority", + email: &parsemail.Email{Subject: "验证码:123456", Text: []byte("验证码:654321")}, + want: "验证码:123456", + }, + { + name: "text priority", + email: &parsemail.Email{Text: []byte("验证码:123456"), HTML: []byte("验证码:654321")}, + want: "验证码:123456", + }, + { + name: "ordinary email unchanged", + email: &parsemail.Email{Subject: "项目进度 2026", Text: []byte("今天已完成开发")}, + want: "<<项目进度 2026>> 今天已完成开发", + }, + { + name: "do not combine parts", + email: &parsemail.Email{Subject: "验证码", Text: []byte("123456")}, + want: "<<验证码>> 123456", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := buildWeChatMessage(tt.email); got != tt.want { + t.Fatalf("通知内容 = %q,期望 %q", got, tt.want) + } + }) + } +} + +func TestExtractWeChatVerificationCodeBoundsInput(t *testing.T) { + inputs := map[string]string{ + "past limit": strings.Repeat(" ", maxWeChatVerificationScanBytes) + "验证码:123456", + "partial code": strings.Repeat(" ", maxWeChatVerificationScanBytes-len("验证码:123456")) + + "验证码:123456789", + } + for name, content := range inputs { + t.Run(name, func(t *testing.T) { + parts := map[string]*parsemail.Email{ + "subject": {Subject: content}, + "text": {Text: []byte(content)}, + "html": {HTML: []byte(content)}, + } + for part, email := range parts { + if code, ok := extractWeChatVerificationCode(email); ok || code != "" { + t.Fatalf("%s 不应提取扫描范围外或被截断的验证码:%q", part, code) + } + } + }) + } +} + +func TestWeChatHTMLNotificationTextIgnoresAttributes(t *testing.T) { + email := &parsemail.Email{ + Subject: "通知", + Text: []byte("查看邮件"), + HTML: []byte(`验证码:123456

普通消息

`), + } + if got := buildWeChatMessage(email); got != "<<通知>> 查看邮件" { + t.Fatalf("HTML 属性不应作为验证码来源:%q", got) + } +} + func TestBuildWeChatMessageLimitsContent(t *testing.T) { message := buildWeChatMessage(&parsemail.Email{ Subject: strings.Repeat("界", maxWeChatMessageBytes),