Skip to content

Commit 5a005ee

Browse files
author
root
committed
fix(telegram): surface verification codes in mail previews
1 parent 74c8e8f commit 5a005ee

6 files changed

Lines changed: 192 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
### Bug Fixes
1616

17+
- fix: |Telegram| 邮件推送支持从 HTML 正文回退提取文本,并优先识别 4-8 位验证码,兼容空格、连字符、HTML entity 等常见格式,避免验证码邮件只显示“解析失败,请打开 mini app 查看”
1718
- fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码
1819
- fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段
1920
- fix: |AI 提取| 将 AI 邮件识别默认 Workers AI 模型切换为支持 JSON Mode 且未弃用的 `@cf/meta/llama-3.1-8b-instruct-fast`,并在文档中补充 `@cf/zai-org/glm-4.7-flash` 结构化输出兼容性提示(issue #1029

CHANGELOG_EN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
### Bug Fixes
1616

17+
- fix: |Telegram| Support falling back from HTML mail bodies to plain text in Telegram push messages, and surface 4-8 digit verification codes first, including common formats with spaces, separators, and HTML entities, instead of only showing “please open the mini app”
1718
- fix: |Admin| Hash address passwords in the frontend before admin reset requests, and make the backend accept and store only the hash instead of plaintext
1819
- fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields
1920
- fix: |AI Extract| Switch the default Workers AI model for AI email recognition to the JSON Mode-compatible, non-deprecated `@cf/meta/llama-3.1-8b-instruct-fast`, and document structured-output compatibility guidance for `@cf/zai-org/glm-4.7-flash` (issue #1029)

pages/wrangler.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name = "temp-email-pages"
1+
name = "cloudflare-temp-email-pages"
22
pages_build_output_dir = "../frontend/dist"
33
compatibility_date = "2024-05-13"
44

worker/src/telegram_api/telegram.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { RawMailRow } from "../models";
1414
import { UserFromGetMe } from "telegraf/types";
1515
import i18n from "../i18n";
1616
import { LocaleMessages } from "../i18n/type";
17+
import { buildSearchableMailText, extractVerificationCode, htmlToPlainText } from "./verification_code";
1718

1819

1920
// Helper to get messages by userId
@@ -388,17 +389,24 @@ const parseMail = async (
388389
}
389390
try {
390391
const parsedEmail = await commonParseMail(parsedEmailContext);
391-
let parsedText = parsedEmail?.text || "";
392+
const htmlText = parsedEmail?.html ? htmlToPlainText(parsedEmail.html) : "";
393+
let parsedText = parsedEmail?.text || htmlText;
394+
const verificationCode = extractVerificationCode(
395+
buildSearchableMailText(parsedEmail?.subject, parsedEmail?.text, parsedEmail?.html)
396+
);
392397
if (parsedText.length && parsedText.length > 1000) {
393-
parsedText = parsedEmail?.text.substring(0, 1000) + `\n\n...\n${msgs.TgMsgTooLongMsg}`;
398+
parsedText = parsedText.substring(0, 1000) + `\n\n...\n${msgs.TgMsgTooLongMsg}`;
394399
}
400+
const content = verificationCode
401+
? `验证码:${verificationCode}` + (parsedText ? `\n\n${parsedText}` : "")
402+
: (parsedText || msgs.TgParseFailedViewInAppMsg);
395403
return {
396404
isHtml: false,
397405
mail: `From: ${parsedEmail?.sender || msgs.TgNoSenderMsg}\n`
398406
+ `To: ${address}\n`
399407
+ (created_at ? `Date: ${created_at}\n` : "")
400408
+ `Subject: ${parsedEmail?.subject}\n`
401-
+ `Content:\n${parsedText || msgs.TgParseFailedViewInAppMsg}`
409+
+ `Content:\n${content}`
402410
};
403411
} catch (e) {
404412
return {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import assert from "node:assert/strict";
2+
import { describe, it } from "node:test";
3+
4+
import { buildSearchableMailText, extractVerificationCode, htmlToPlainText } from "./verification_code.ts";
5+
6+
describe("verification code extraction", () => {
7+
it("extracts a ChatGPT code from HTML-only mail", () => {
8+
const html = `<html><body><p>Your temporary ChatGPT login code is:</p><div style="font-size:32px">123456</div><p>This code expires in 10 minutes.</p></body></html>`;
9+
const body = buildSearchableMailText("Your temporary ChatGPT login code", "", html);
10+
11+
assert.equal(extractVerificationCode(body), "123456");
12+
});
13+
14+
it("extracts spaced and separated numeric codes", () => {
15+
const text = "您的验证码为 1 2 3-4_5.6,请勿泄露。";
16+
17+
assert.equal(extractVerificationCode(text), "123456");
18+
});
19+
20+
it("supports 4 and 8 digit verification codes", () => {
21+
assert.equal(extractVerificationCode("Login PIN: 0428. It expires soon."), "0428");
22+
assert.equal(extractVerificationCode("安全验证码:87654321,5分钟内有效"), "87654321");
23+
});
24+
25+
it("prefers code-related context over unrelated numbers", () => {
26+
const text = "订单 987654 于 2026-05-04 创建。验证码是 135790,请在 10 分钟内输入。";
27+
28+
assert.equal(extractVerificationCode(text), "135790");
29+
});
30+
31+
it("converts HTML entities and basic tags to readable text", () => {
32+
const html = "<div>验证码:&#49;&#50;&#51;&#52;&#53;&#54;</div><br><span>请勿泄露&nbsp;code</span>";
33+
34+
assert.match(htmlToPlainText(html), /123456/);
35+
assert.match(htmlToPlainText(html), / code/);
36+
});
37+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
const MAX_CODE_CONTEXT_LENGTH = 120;
2+
3+
const htmlEntityMap: Record<string, string> = {
4+
amp: "&",
5+
lt: "<",
6+
gt: ">",
7+
quot: '"',
8+
apos: "'",
9+
nbsp: " ",
10+
ensp: " ",
11+
emsp: " ",
12+
thinsp: " ",
13+
zwnj: "",
14+
zwj: "",
15+
};
16+
17+
export const decodeHtmlEntities = (value: string): string => {
18+
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (entity, code: string) => {
19+
const lowerCode = code.toLowerCase();
20+
if (lowerCode.startsWith("#x")) {
21+
const charCode = parseInt(lowerCode.slice(2), 16);
22+
return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
23+
}
24+
if (lowerCode.startsWith("#")) {
25+
const charCode = parseInt(lowerCode.slice(1), 10);
26+
return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
27+
}
28+
return htmlEntityMap[lowerCode] ?? entity;
29+
});
30+
};
31+
32+
export const htmlToPlainText = (html: string): string => {
33+
return decodeHtmlEntities(html)
34+
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, " ")
35+
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
36+
.replace(/<\s*\/\s*(p|div|tr|td|th|li|h[1-6]|table|section|article)\s*>/gi, "\n")
37+
.replace(/<[^>]+>/g, " ")
38+
.replace(/[\u200B-\u200D\uFEFF]/g, "")
39+
.replace(/[ \t\r\f\v]+/g, " ")
40+
.replace(/\n\s+/g, "\n")
41+
.replace(/\n{3,}/g, "\n\n")
42+
.trim();
43+
};
44+
45+
export const buildSearchableMailText = (subject?: string, text?: string, html?: string): string => {
46+
return [subject || "", text || "", html ? htmlToPlainText(html) : ""]
47+
.filter(part => part.trim().length > 0)
48+
.join("\n");
49+
};
50+
51+
const compactCandidateCode = (value: string): string => {
52+
return value.replace(/[\s\-_.:]/g, "");
53+
};
54+
55+
const isLikelyDateOrTime = (code: string, context: string): boolean => {
56+
if (/^(19|20)\d{2}$/.test(code)) {
57+
return true;
58+
}
59+
if (/^\d{8}$/.test(code) && /(?:date|||time|expires?||)/i.test(context)) {
60+
return true;
61+
}
62+
if (/^\d{4}$/.test(code) && /[:]\s*\d{2}/.test(context)) {
63+
return true;
64+
}
65+
return false;
66+
};
67+
68+
const distanceToNearestKeyword = (context: string, codeStartInContext: number): number => {
69+
const keywordPattern = /(verification|verify|login|sign.?in|security|auth|authentication|one.?time|otp|passcode|code|pin|temporary|||||||||||)/ig;
70+
let nearest = Number.POSITIVE_INFINITY;
71+
let match: RegExpExecArray | null;
72+
while ((match = keywordPattern.exec(context)) !== null) {
73+
const keywordStart = match.index;
74+
const keywordEnd = match.index + match[0].length;
75+
const distance = codeStartInContext < keywordStart
76+
? keywordStart - codeStartInContext
77+
: Math.max(0, codeStartInContext - keywordEnd);
78+
nearest = Math.min(nearest, distance);
79+
}
80+
return nearest;
81+
};
82+
83+
const scoreCandidate = (code: string, context: string, index: number, codeStartInContext: number): number => {
84+
let score = 0;
85+
86+
if (code.length === 6) score += 30;
87+
if (code.length === 8) score += 20;
88+
if (code.length === 4) score += 10;
89+
if (code.length >= 5 && code.length <= 8) score += 10;
90+
91+
const keywordDistance = distanceToNearestKeyword(context, codeStartInContext);
92+
if (Number.isFinite(keywordDistance)) {
93+
score += Math.max(0, 90 - keywordDistance);
94+
}
95+
if (/(|||||)/.test(context)) {
96+
score += 20;
97+
}
98+
if (/(openai|chatgpt|google|github|telegram|microsoft|apple|discord|cloudflare)/i.test(context)) {
99+
score += 15;
100+
}
101+
if (/\b(code|pin|otp)\b\s*(?:is|:||-)/i.test(context) || /(?:|||)\s*(?:||:|)/.test(context)) {
102+
score += 55;
103+
}
104+
if (/(?:expires?|expire|valid||||minutes?|mins?)/i.test(context)) {
105+
score += 5;
106+
}
107+
if (isLikelyDateOrTime(code, context)) {
108+
score -= 100;
109+
}
110+
111+
score -= Math.min(index / 100, 15);
112+
return score;
113+
};
114+
115+
export const extractVerificationCode = (input: string): string => {
116+
const normalized = decodeHtmlEntities(input)
117+
.replace(/[\u200B-\u200D\uFEFF]/g, "")
118+
.replace(/[-]/g, char => String.fromCharCode(char.charCodeAt(0) - 0xFEE0));
119+
const candidates: { code: string; score: number; index: number }[] = [];
120+
const seen = new Set<string>();
121+
const codePattern = /(?<!\d)(?:\d[\s\-_.:]?){4,8}\d?(?!\d)/g;
122+
let match: RegExpExecArray | null;
123+
124+
while ((match = codePattern.exec(normalized)) !== null) {
125+
const code = compactCandidateCode(match[0]);
126+
if (!/^\d{4,8}$/.test(code) || seen.has(code)) {
127+
continue;
128+
}
129+
const start = Math.max(0, match.index - MAX_CODE_CONTEXT_LENGTH);
130+
const end = Math.min(normalized.length, match.index + match[0].length + MAX_CODE_CONTEXT_LENGTH);
131+
const context = normalized.slice(start, end);
132+
const score = scoreCandidate(code, context, match.index, match.index - start);
133+
if (score > 0) {
134+
candidates.push({ code, score, index: match.index });
135+
seen.add(code);
136+
}
137+
}
138+
139+
candidates.sort((a, b) => b.score - a.score || a.index - b.index);
140+
return candidates[0]?.code || "";
141+
};

0 commit comments

Comments
 (0)