Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### Bug Fixes

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

### Bug Fixes

- 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”
- 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
- fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields
- 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)
Expand Down
2 changes: 1 addition & 1 deletion pages/wrangler.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name = "temp-email-pages"
name = "cloudflare-temp-email-pages"
pages_build_output_dir = "../frontend/dist"
compatibility_date = "2024-05-13"

Expand Down
14 changes: 11 additions & 3 deletions worker/src/telegram_api/telegram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RawMailRow } from "../models";
import { UserFromGetMe } from "telegraf/types";
import i18n from "../i18n";
import { LocaleMessages } from "../i18n/type";
import { buildSearchableMailText, extractVerificationCode, htmlToPlainText } from "./verification_code";


// Helper to get messages by userId
Expand Down Expand Up @@ -388,17 +389,24 @@ const parseMail = async (
}
try {
const parsedEmail = await commonParseMail(parsedEmailContext);
let parsedText = parsedEmail?.text || "";
const htmlText = parsedEmail?.html ? htmlToPlainText(parsedEmail.html) : "";
let parsedText = parsedEmail?.text || htmlText;
const verificationCode = extractVerificationCode(
buildSearchableMailText(parsedEmail?.subject, parsedEmail?.text, parsedEmail?.html)
);
if (parsedText.length && parsedText.length > 1000) {
parsedText = parsedEmail?.text.substring(0, 1000) + `\n\n...\n${msgs.TgMsgTooLongMsg}`;
parsedText = parsedText.substring(0, 1000) + `\n\n...\n${msgs.TgMsgTooLongMsg}`;
}
const content = verificationCode
? `验证码:${verificationCode}` + (parsedText ? `\n\n${parsedText}` : "")
: (parsedText || msgs.TgParseFailedViewInAppMsg);
Comment on lines +400 to +402

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

验证码标签硬编码中文,破坏多语言一致性。

文件中所有面向用户的文案(TgMsgTooLongMsg / TgParseFailedViewInAppMsg / TgNoSenderMsg 等)都走 msgs: LocaleMessages,且 Bot 已通过 /lang 支持中英文切换。此处直接拼接 验证码:,英文用户也会看到中文前缀,与现有 i18n 约定不一致。建议在 LocaleMessages 中新增对应 key(如 TgVerificationCodeLabel),中文 验证码、英文 Verification code

♻️ 建议改为走 i18n
         const content = verificationCode
-            ? `验证码:${verificationCode}` + (parsedText ? `\n\n${parsedText}` : "")
+            ? `${msgs.TgVerificationCodeLabel}: ${verificationCode}` + (parsedText ? `\n\n${parsedText}` : "")
             : (parsedText || msgs.TgParseFailedViewInAppMsg);

并在 worker/src/i18n 中为中英文各加一条 TgVerificationCodeLabel(如 验证码 / Verification code)。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/telegram_api/telegram.ts` around lines 400 - 402, The content
concatenation hardcodes the Chinese prefix "验证码:" which breaks i18n; update the
logic that builds content (the const named content that uses verificationCode
and parsedText) to use a new LocaleMessages key (e.g., TgVerificationCodeLabel)
from msgs instead of the hardcoded string, then add TgVerificationCodeLabel to
the i18n entries for both languages (Chinese "验证码" and English "Verification
code") so content becomes `${msgs.TgVerificationCodeLabel}:${verificationCode}`
(keeping the parsedText fallback behavior).

return {
isHtml: false,
mail: `From: ${parsedEmail?.sender || msgs.TgNoSenderMsg}\n`
+ `To: ${address}\n`
+ (created_at ? `Date: ${created_at}\n` : "")
+ `Subject: ${parsedEmail?.subject}\n`
+ `Content:\n${parsedText || msgs.TgParseFailedViewInAppMsg}`
+ `Content:\n${content}`
};
} catch (e) {
return {
Expand Down
37 changes: 37 additions & 0 deletions worker/src/telegram_api/verification_code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { buildSearchableMailText, extractVerificationCode, htmlToPlainText } from "./verification_code.ts";

describe("verification code extraction", () => {
it("extracts a ChatGPT code from HTML-only mail", () => {
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>`;
const body = buildSearchableMailText("Your temporary ChatGPT login code", "", html);

assert.equal(extractVerificationCode(body), "123456");
});

it("extracts spaced and separated numeric codes", () => {
const text = "您的验证码为 1 2 3-4_5.6,请勿泄露。";

assert.equal(extractVerificationCode(text), "123456");
});

it("supports 4 and 8 digit verification codes", () => {
assert.equal(extractVerificationCode("Login PIN: 0428. It expires soon."), "0428");
assert.equal(extractVerificationCode("安全验证码:87654321,5分钟内有效"), "87654321");
});

it("prefers code-related context over unrelated numbers", () => {
const text = "订单 987654 于 2026-05-04 创建。验证码是 135790,请在 10 分钟内输入。";

assert.equal(extractVerificationCode(text), "135790");
});

it("converts HTML entities and basic tags to readable text", () => {
const html = "<div>验证码:&#49;&#50;&#51;&#52;&#53;&#54;</div><br><span>请勿泄露&nbsp;code</span>";

assert.match(htmlToPlainText(html), /验证码:123456/);
assert.match(htmlToPlainText(html), /请勿泄露 code/);
});
});
141 changes: 141 additions & 0 deletions worker/src/telegram_api/verification_code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
const MAX_CODE_CONTEXT_LENGTH = 120;

const htmlEntityMap: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ensp: " ",
emsp: " ",
thinsp: " ",
zwnj: "",
zwj: "",
};

export const decodeHtmlEntities = (value: string): string => {
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (entity, code: string) => {
const lowerCode = code.toLowerCase();
if (lowerCode.startsWith("#x")) {
const charCode = parseInt(lowerCode.slice(2), 16);
return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
}
if (lowerCode.startsWith("#")) {
const charCode = parseInt(lowerCode.slice(1), 10);
return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
}
return htmlEntityMap[lowerCode] ?? entity;
});
};
Comment on lines +17 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

String.fromCodePoint 可能因越界码点抛出 RangeError

Number.isFinite 只能拦截 NaN,但对于 &#999999999; 这类超过 0x10FFFF 的数值实体,parseInt 会返回合法的有限数,String.fromCodePoint 会抛出 RangeError。虽然 parseMail 有 try/catch 兜底,但会导致验证码提取整段失败,建议显式校验码点范围后再调用。

🛡️ 建议加入码点范围校验
 export const decodeHtmlEntities = (value: string): string => {
     return value.replace(/&(`#x`?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (entity, code: string) => {
         const lowerCode = code.toLowerCase();
         if (lowerCode.startsWith("#x")) {
             const charCode = parseInt(lowerCode.slice(2), 16);
-            return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
+            return Number.isFinite(charCode) && charCode >= 0 && charCode <= 0x10FFFF
+                ? String.fromCodePoint(charCode) : entity;
         }
         if (lowerCode.startsWith("#")) {
             const charCode = parseInt(lowerCode.slice(1), 10);
-            return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
+            return Number.isFinite(charCode) && charCode >= 0 && charCode <= 0x10FFFF
+                ? String.fromCodePoint(charCode) : entity;
         }
         return htmlEntityMap[lowerCode] ?? entity;
     });
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const decodeHtmlEntities = (value: string): string => {
return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (entity, code: string) => {
const lowerCode = code.toLowerCase();
if (lowerCode.startsWith("#x")) {
const charCode = parseInt(lowerCode.slice(2), 16);
return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
}
if (lowerCode.startsWith("#")) {
const charCode = parseInt(lowerCode.slice(1), 10);
return Number.isFinite(charCode) ? String.fromCodePoint(charCode) : entity;
}
return htmlEntityMap[lowerCode] ?? entity;
});
};
export const decodeHtmlEntities = (value: string): string => {
return value.replace(/&(`#x`?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (entity, code: string) => {
const lowerCode = code.toLowerCase();
if (lowerCode.startsWith("#x")) {
const charCode = parseInt(lowerCode.slice(2), 16);
return Number.isFinite(charCode) && charCode >= 0 && charCode <= 0x10FFFF
? String.fromCodePoint(charCode) : entity;
}
if (lowerCode.startsWith("#")) {
const charCode = parseInt(lowerCode.slice(1), 10);
return Number.isFinite(charCode) && charCode >= 0 && charCode <= 0x10FFFF
? String.fromCodePoint(charCode) : entity;
}
return htmlEntityMap[lowerCode] ?? entity;
});
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/telegram_api/verification_code.ts` around lines 17 - 30, The
decodeHtmlEntities function can call String.fromCodePoint with out-of-range
values (e.g., parseInt("999999999")), causing a RangeError; update its
numeric-entity handling (the branches that parse hex and decimal using parseInt)
to validate the parsed charCode is an integer within [0, 0x10FFFF] before
calling String.fromCodePoint and otherwise fall back to returning the original
entity; ensure both the "#x" and "#" branches perform this check so
decodeHtmlEntities no longer throws for oversized code points.


export const htmlToPlainText = (html: string): string => {
return decodeHtmlEntities(html)
.replace(/<\s*(script|style)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, " ")
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\s*\/\s*(p|div|tr|td|th|li|h[1-6]|table|section|article)\s*>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/[\u200B-\u200D\uFEFF]/g, "")
.replace(/[ \t\r\f\v]+/g, " ")
.replace(/\n\s+/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
};

export const buildSearchableMailText = (subject?: string, text?: string, html?: string): string => {
return [subject || "", text || "", html ? htmlToPlainText(html) : ""]
.filter(part => part.trim().length > 0)
.join("\n");
};

const compactCandidateCode = (value: string): string => {
return value.replace(/[\s\-_.::]/g, "");
};

const isLikelyDateOrTime = (code: string, context: string): boolean => {
if (/^(19|20)\d{2}$/.test(code)) {
return true;
}
if (/^\d{8}$/.test(code) && /(?:date|日期|时间|time|expires?|过期|有效期)/i.test(context)) {
return true;
}
if (/^\d{4}$/.test(code) && /[::]\s*\d{2}/.test(context)) {
return true;
}
return false;
};
Comment on lines +55 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

4 位验证码会因相邻的“code: ”等上下文被误判为时间,导致 -100 惩罚后被丢弃。

第 62 行使用 /[::]\s*\d{2}/.test(context) 在 ±120 字符上下文里搜索“冒号+两位数字”。对于常见提示语 Your code: 1234验证码:1234,上下文中的 : 12/:12 同样会命中该模式,从而把合法的 4 位 OTP 标记为时间,叠加 scoreCandidate-100 的扣分,几乎一定使其得分变负而被过滤。

建议只在数字未紧邻 code 本身时判定为时间,例如把整段时间形如 HH:MM 的判断改为仅匹配 4 位代码自身被冒号一分为二的情况,或者在匹配时排除 code 自身所在区间。

🛠️ 建议修正
-    if (/^\d{4}$/.test(code) && /[::]\s*\d{2}/.test(context)) {
-        return true;
-    }
+    // 仅当 4 位代码本身呈现为 "HH:MM" 形式时才视为时间
+    if (/^\d{4}$/.test(code) && new RegExp(`${code[0]}${code[1]}[::]${code[2]}${code[3]}`).test(context)) {
+        return true;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isLikelyDateOrTime = (code: string, context: string): boolean => {
if (/^(19|20)\d{2}$/.test(code)) {
return true;
}
if (/^\d{8}$/.test(code) && /(?:date|||time|expires?||)/i.test(context)) {
return true;
}
if (/^\d{4}$/.test(code) && /[:]\s*\d{2}/.test(context)) {
return true;
}
return false;
};
const isLikelyDateOrTime = (code: string, context: string): boolean => {
if (/^(19|20)\d{2}$/.test(code)) {
return true;
}
if (/^\d{8}$/.test(code) && /(?:date|||time|expires?||)/i.test(context)) {
return true;
}
// 仅当 4 位代码本身呈现为 "HH:MM" 形式时才视为时间
if (/^\d{4}$/.test(code) && new RegExp(`${code[0]}${code[1]}[::]${code[2]}${code[3]}`).test(context)) {
return true;
}
return false;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@worker/src/telegram_api/verification_code.ts` around lines 55 - 66, The
colon+two-digits check in isLikelyDateOrTime falsely tags 4-digit OTPs when the
surrounding context has labels like "code: 1234"; change the logic so you only
treat a 4-digit string as time when the context contains the code itself split
by a colon (e.g. "12:34" or "12:34") rather than any colon followed by two
digits anywhere: replace the /^\d{4}$/.test(code) &&
/[::]\s*\d{2}/.test(context) branch with a check that builds and searches for
the code split by a colon (e.g. new
RegExp(`${code.slice(0,2)}[::]\\s*${code.slice(2)}`)) or otherwise verifies the
matched HH:MM substring includes the code characters, so labels like "code:" or
"验证码:" do not trigger a time match; update isLikelyDateOrTime accordingly (and
any callers such as scoreCandidate) to avoid the -100 penalty for legitimate
OTPs.


const distanceToNearestKeyword = (context: string, codeStartInContext: number): number => {
const keywordPattern = /(verification|verify|login|sign.?in|security|auth|authentication|one.?time|otp|passcode|code|pin|temporary|确认|验证|验证码|校验码|动态码|登录|登入|安全|口令|一次性|临时)/ig;
let nearest = Number.POSITIVE_INFINITY;
let match: RegExpExecArray | null;
while ((match = keywordPattern.exec(context)) !== null) {
const keywordStart = match.index;
const keywordEnd = match.index + match[0].length;
const distance = codeStartInContext < keywordStart
? keywordStart - codeStartInContext
: Math.max(0, codeStartInContext - keywordEnd);
nearest = Math.min(nearest, distance);
}
return nearest;
};

const scoreCandidate = (code: string, context: string, index: number, codeStartInContext: number): number => {
let score = 0;

if (code.length === 6) score += 30;
if (code.length === 8) score += 20;
if (code.length === 4) score += 10;
if (code.length >= 5 && code.length <= 8) score += 10;

const keywordDistance = distanceToNearestKeyword(context, codeStartInContext);
if (Number.isFinite(keywordDistance)) {
score += Math.max(0, 90 - keywordDistance);
}
if (/(验证码|校验码|动态码|一次性代码|登录代码|安全代码)/.test(context)) {
score += 20;
}
if (/(openai|chatgpt|google|github|telegram|microsoft|apple|discord|cloudflare)/i.test(context)) {
score += 15;
}
if (/\b(code|pin|otp)\b\s*(?:is|:|:|-)/i.test(context) || /(?:验证码|代码|校验码|动态码)\s*(?:是|为|:|:)/.test(context)) {
score += 55;
}
if (/(?:expires?|expire|valid|有效|过期|分钟|minutes?|mins?)/i.test(context)) {
score += 5;
}
if (isLikelyDateOrTime(code, context)) {
score -= 100;
}

score -= Math.min(index / 100, 15);
return score;
};

export const extractVerificationCode = (input: string): string => {
const normalized = decodeHtmlEntities(input)
.replace(/[\u200B-\u200D\uFEFF]/g, "")
.replace(/[0-9]/g, char => String.fromCharCode(char.charCodeAt(0) - 0xFEE0));
const candidates: { code: string; score: number; index: number }[] = [];
const seen = new Set<string>();
const codePattern = /(?<!\d)(?:\d[\s\-_.::]?){4,8}\d?(?!\d)/g;
let match: RegExpExecArray | null;

while ((match = codePattern.exec(normalized)) !== null) {
const code = compactCandidateCode(match[0]);
if (!/^\d{4,8}$/.test(code) || seen.has(code)) {
continue;
}
const start = Math.max(0, match.index - MAX_CODE_CONTEXT_LENGTH);
const end = Math.min(normalized.length, match.index + match[0].length + MAX_CODE_CONTEXT_LENGTH);
const context = normalized.slice(start, end);
const score = scoreCandidate(code, context, match.index, match.index - start);
if (score > 0) {
candidates.push({ code, score, index: match.index });
seen.add(code);
}
}

candidates.sort((a, b) => b.score - a.score || a.index - b.index);
return candidates[0]?.code || "";
};