From 942605b2b65460018ae5954c92c6a221b18e26e8 Mon Sep 17 00:00:00 2001 From: zxyszx <299979470+zxyszx@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:53:21 +0800 Subject: [PATCH] fix: refine Telegram links and mailbox selector --- .github/release-notes/v1.2.18.md | 4 ++ .github/workflows/docker.yml | 12 ++-- apps/api/internal/app/telegram.go | 87 +++++++++++++++++++++++++- apps/api/internal/app/telegram_test.go | 36 +++++++++++ apps/web/src/pages/mail.tsx | 2 +- docs/ISSUE_LEDGER.md | 23 +++++++ 6 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 .github/release-notes/v1.2.18.md diff --git a/.github/release-notes/v1.2.18.md b/.github/release-notes/v1.2.18.md new file mode 100644 index 0000000..90903a0 --- /dev/null +++ b/.github/release-notes/v1.2.18.md @@ -0,0 +1,4 @@ +- 修复邮箱选择列表超过侧栏边框的问题,展开列表现在与上方选择框保持相同宽度。 +- 修复含日期年份的邮件可能漏识别验证码的问题,Gate 等验证码邮件可正常显示一键复制按钮。 +- 邮件通知中的网址改为可点击链接,超长追踪地址使用简短文字显示,阅读更清晰。 +- 版本频道通知移除底部按钮,改为正文中的“查看本次更新”文字链接。 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index bf784d3..49ae4b6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -298,7 +298,9 @@ jobs: tag = os.environ["RELEASE_TAG"] prefix = f"NewSzxcn Email {html.escape(tag)}\n新版本现已发布\n\n本次更新\n\n" - available = max(0, 3600 - len(prefix)) + release_url = html.escape(os.environ["RELEASE_URL"], quote=True) + footer = f'\n\n🔗 查看本次更新' + available = max(0, 3600 - len(prefix) - len(footer)) visible_sections = [] used = 0 for section in sections: @@ -309,20 +311,18 @@ jobs: used += added body = "\n\n".join(visible_sections) if len(visible_sections) < len(sections): - body += "\n\n更新内容较长,请点击下方按钮查看完整内容。" - open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body) + body += "\n\n更新内容较长,请打开下方链接查看完整内容。" + open("telegram-release-message.txt", "w", encoding="utf-8").write(prefix + body + footer) PY jq -n \ --arg chat_id "${TELEGRAM_CHAT_ID}" \ - --arg release_url "${RELEASE_URL}" \ --rawfile text telegram-release-message.txt \ '{ chat_id:$chat_id, text:$text, parse_mode:"HTML", - disable_web_page_preview:true, - reply_markup:{inline_keyboard:[[{text:"查看本次更新 ↗",url:$release_url}]]} + disable_web_page_preview:true }' > telegram-release-payload.json http_code="$(curl -sS --retry 2 --retry-all-errors --connect-timeout 10 --max-time 30 \ diff --git a/apps/api/internal/app/telegram.go b/apps/api/internal/app/telegram.go index c5f0f64..fb55686 100644 --- a/apps/api/internal/app/telegram.go +++ b/apps/api/internal/app/telegram.go @@ -473,6 +473,7 @@ func sanitizeTelegramAttachmentName(value string) string { var ( telegramOTPKeywordRe = regexp.MustCompile(`(?i)(验证码|校验码|动态码|登录码|安全码|一次性密码|otp|verification[ -]?code|security[ -]?code|login[ -]?code|passcode|one[ -]?time[ -]?(?:password|code))`) telegramOTPCandidateRe = regexp.MustCompile(`(?i)[a-z0-9]{4,10}`) + telegramURLRe = regexp.MustCompile(`(?i)https?://[^\s<>"']+`) ) func detectTelegramOTP(subject, body string) string { @@ -506,6 +507,9 @@ func detectTelegramOTP(subject, body string) string { if !hasDigit || telegramOTPKeywordRe.MatchString(value) { continue } + if isTelegramOTPNonCode(value) { + continue + } best := 0 for _, keyword := range keywords { distance := match[0] - keyword[1] @@ -557,6 +561,20 @@ func detectTelegramOTP(subject, body string) string { return items[0].value } +func isTelegramOTPNonCode(value string) bool { + if len(value) == 4 { + if year, err := strconv.Atoi(value); err == nil && year >= 1900 && year <= 2099 { + return true + } + } + if len(value) == 8 { + if _, err := time.Parse("20060102", value); err == nil { + return true + } + } + return false +} + func isTelegramOTPAlphaNumeric(r rune) bool { return r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) } @@ -723,7 +741,7 @@ func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMes } prefix := strings.Join(lines, "\n") + "\n\n" + label + "\n
" suffix := "
" - body = escapeTelegramWithinBudget(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix)) + body = formatTelegramBodyHTML(body, telegramMessageBudget-utf8.RuneCountInString(prefix)-utf8.RuneCountInString(suffix)) lines = []string{prefix + body + suffix} } htmlText := strings.Join(lines, "\n") @@ -731,6 +749,73 @@ func formatTelegramMailMessage(payload telegramMailPayload) telegramFormattedMes return telegramFormattedMessage{HTML: htmlText, PlainText: plain, OTP: payload.OTP} } +func formatTelegramBodyHTML(value string, budget int) string { + if budget <= 3 { + return "" + } + var out strings.Builder + used := 0 + truncated := false + appendEscaped := func(text string) bool { + for _, r := range text { + escaped := html.EscapeString(string(r)) + length := utf8.RuneCountInString(escaped) + if used+length > budget-3 { + return false + } + out.WriteString(escaped) + used += length + } + return true + } + last := 0 + for _, match := range telegramURLRe.FindAllStringIndex(value, -1) { + if !appendEscaped(value[last:match[0]]) { + truncated = true + break + } + rawURL, trailing := trimTelegramURL(value[match[0]:match[1]]) + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + if !appendEscaped(value[match[0]:match[1]]) { + truncated = true + break + } + last = match[1] + continue + } + display := rawURL + if utf8.RuneCountInString(display) > 72 { + display = "🔗 " + parsed.Hostname() + " 链接" + } + anchor := `` + html.EscapeString(display) + `` + length := utf8.RuneCountInString(anchor) + if used+length > budget-3 { + truncated = true + break + } + out.WriteString(anchor) + used += length + if !appendEscaped(trailing) { + truncated = true + break + } + last = match[1] + } + if !truncated && last < len(value) && !appendEscaped(value[last:]) { + truncated = true + } + if truncated { + out.WriteString("...") + } + return out.String() +} + +func trimTelegramURL(value string) (string, string) { + trimmed := strings.TrimRight(value, ".,;:!?)]},。;:!?)》】") + return trimmed, value[len(trimmed):] +} + func (a *App) sendTelegramMessage(ctx context.Context, token, chatID, text string) error { _, err := a.sendTelegramPayload(ctx, token, map[string]any{ "chat_id": chatID, diff --git a/apps/api/internal/app/telegram_test.go b/apps/api/internal/app/telegram_test.go index d98dc25..328f3cf 100644 --- a/apps/api/internal/app/telegram_test.go +++ b/apps/api/internal/app/telegram_test.go @@ -223,6 +223,42 @@ func TestTelegramOTPDetectionAndMessageBudget(t *testing.T) { } } +func TestTelegramForwardedGateOTPAndLinks(t *testing.T) { + body := `---------- Forwarded message --------- +Date: 2026年8月6日周四 17:59 +Subject: 登录验证码 (https://www.gate.com) + +Gate 检测到您的账号正试图从此 IP 获得登录验证码: +IP: 87.83.105.229 +如为您本人登录,请输入如下验证码完成操作: +311665 +如非本人操作,请点击此处禁用账户 ` + if otp := detectTelegramOTP("Fwd: 登录验证码 (https://www.gate.com)", body); otp != "311665" { + t.Fatalf("forwarded Gate OTP not detected: %q", otp) + } + if otp := detectTelegramOTP("登录验证码", "日期 2026-08-06,验证码将在稍后发送"); otp != "" { + t.Fatalf("year was incorrectly detected as OTP: %q", otp) + } + message := formatTelegramMailMessage(telegramMailPayload{ + From: "no-reply@alert.gate.com", Recipient: "admin@example.com", Subject: "登录验证码", + ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), Body: body, BodyMode: "full", OTP: "311665", + }) + if !strings.Contains(message.HTML, `https://www.gate.com`) { + t.Fatalf("normal URL was not linkified: %s", message.HTML) + } + if !strings.Contains(message.HTML, `>🔗 data.gate.com 链接`) { + t.Fatalf("long tracking URL was not shortened: %s", message.HTML) + } + if strings.Contains(message.HTML, "<a href=") || utf8.RuneCountInString(message.HTML) > telegramMessageBudget { + t.Fatalf("generated Telegram HTML is invalid or too long: %s", message.HTML) + } + markup := telegramCopyMarkup(message.OTP) + buttons, ok := markup["inline_keyboard"].([][]map[string]any) + if !ok || len(buttons) != 1 || len(buttons[0]) != 1 || buttons[0][0]["text"] != "复制验证码" { + t.Fatalf("copy OTP button missing: %#v", markup) + } +} + func TestTelegramPseudoHTMLAndBodyCharset(t *testing.T) { pseudo := `

验证码:778899

欢迎登录
` text := telegramMessageBody(storedMessage{BodyText: pseudo}) diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index b3b8c16..706ccbf 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -3292,7 +3292,7 @@ function MailboxSwitcher({ collapsed, mailboxes, loading, selectedMailboxId, sel align="start" className={cn( "max-w-[calc(100vw-32px)] p-1", - collapsed ? "w-[204px]" : "w-[21rem] min-w-[var(--radix-dropdown-menu-trigger-width)]" + collapsed ? "w-[204px]" : "w-[var(--radix-dropdown-menu-trigger-width)] min-w-0" )} > {mailboxes.length > 0 && ( diff --git a/docs/ISSUE_LEDGER.md b/docs/ISSUE_LEDGER.md index 9553626..6103580 100644 --- a/docs/ISSUE_LEDGER.md +++ b/docs/ISSUE_LEDGER.md @@ -41,6 +41,7 @@ | NSX-20260806-004 | 2026-08-06 | 已完成 | 前端/UI/响应式布局 | “全部邮箱”选择器右侧存在复制按钮空白占位 | S3 | v1.2.15 | 随 v1.2.15 发布 | | NSX-20260806-005 | 2026-08-06 | 已完成 | 后端/通知;前端/设置;部署运维/CI | Telegram 私聊邮件通知与 Release 频道通知 | S3 | v1.2.16 | 随 v1.2.16 发布 | | NSX-20260806-006 | 2026-08-06 | 已完成 | 后端/通知;邮件核心;前端/设置;质量复核 | Telegram 邮件通知安全、验证码复制和可靠性复核 | S2 | v1.2.17 | 随 v1.2.17 发布 | +| NSX-20260806-007 | 2026-08-06 | 待验收 | 前端/UI;后端/通知;部署运维/CI | 邮箱下拉层越界、验证码漏识别、邮件与版本通知链接样式 | S3 | v1.2.18 | 待发布 | ## NSX-20260804-001 @@ -252,3 +253,25 @@ | 2026-08-06 | 用户确认继续修改,并明确保留现有机器人 Token。 | | 2026-08-06 | 完成实现和自动化回归,状态流转为待验收。 | | 2026-08-06 | 完成桌面端与移动端页面验收及最终回归,状态流转为已完成。 | + +## NSX-20260806-007 + +| 字段 | 内容 | +| --- | --- | +| 编号 | NSX-20260806-007 | +| 日期 | 2026-08-06 | +| 状态 | 待验收 | +| 模块 | 前端/UI;后端/通知;部署运维/CI;质量复核 | +| 现象 | 邮箱选择器展开层超过侧栏边框;含日期年份的验证码邮件未显示复制按钮;邮件长链接难以阅读;Release 通知底部按钮需改为正文文字链接。 | +| 根因 | 展开层固定为 21rem,未跟随触发按钮;年份与真实验证码同时进入评分后触发歧义保护;正文仅转义未生成显式链接;Release 工作流使用 inline keyboard。 | +| 实现 | 展开层宽度跟随触发按钮;排除年份和紧凑日期候选;正文 URL 安全转义并生成链接,长追踪地址缩短显示;Release 移除按钮并在正文末尾加入“查看本次更新”链接。 | +| 目标版本 | v1.2.18 | +| 测试结果 | Gate 转发邮件验证码与链接专项测试、Telegram 全部竞态测试、Go 全量测试和 vet、前端 check/build、工作流 YAML、差异格式检查均通过;桌面端“全部邮箱”和具体邮箱状态下触发按钮与下拉层均为 263px,左右边界一致且无控制台错误。 | +| 发布状态 | 待发布。 | + +### 历史 + +| 时间 | 记录 | +| --- | --- | +| 2026-08-06 | 用户提供三张截图并确认本批修改范围,问题进入处理中。 | +| 2026-08-06 | 完成实现、自动化回归和桌面端视觉验收,状态流转为待验收。 |