From cfb31807f1ea826ea8d97f8123f71f2a23d16115 Mon Sep 17 00:00:00 2001 From: Dream Hunter Date: Fri, 29 May 2026 01:48:02 +0800 Subject: [PATCH] fix: keep Telegram AI extract result on metadata errors (#1045) --- CHANGELOG.md | 1 + CHANGELOG_EN.md | 1 + e2e/tests/api/telegram-ai-extract.spec.ts | 55 ++++++++++++++ worker/src/admin_api/e2e_test_api.ts | 18 ++++- worker/src/email/ai_extract.ts | 35 ++++----- worker/src/models/index.ts | 6 ++ worker/src/telegram_api/telegram.ts | 88 +++++++++++------------ 7 files changed, 134 insertions(+), 70 deletions(-) create mode 100644 e2e/tests/api/telegram-ai-extract.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41cc0b91..97310daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features +- feat: |Telegram| Telegram 新邮件推送与 `/mails` 历史邮件查看支持展示 AI 提取结果,包含验证码、验证链接、服务链接、订阅链接等关键信息 - feat: |Frontend| 新增 `DISABLE_SHOW_GITHUB_FOR_USER` 配置,可仅对普通用户隐藏 Header 的 GitHub/版本入口,admin 仍可见(issue #1041) - feat: |Frontend| 将邮箱地址凭证弹窗升级为“地址凭证与连接方式”,复用普通用户与 admin 创建邮箱结果弹窗;支持通过 `ENABLE_AGENT_EMAIL_INFO` 展示 AI Agent 接入信息,并通过 `SMTP_IMAP_PROXY_CONFIG` 展示 SMTP/IMAP 客户端连接信息 - docs: |随机子域名| 在前端“启用随机子域名”提示与 `subdomain` / `worker-vars` 文档(中英)中明确说明:要让 `name@<随机>.abc.com` 真正收到邮件,必须在基础域名 DNS 中为 `*` 子域添加通配 MX 记录,Email Routing 子域不继承父域配置(issue #1035) diff --git a/CHANGELOG_EN.md b/CHANGELOG_EN.md index 770e25bd..c4d92653 100644 --- a/CHANGELOG_EN.md +++ b/CHANGELOG_EN.md @@ -10,6 +10,7 @@ ### Features +- feat: |Telegram| Show AI extraction results in Telegram new-mail notifications and `/mails` history views, including verification codes, auth links, service links, and subscription links - feat: |Frontend| Add `DISABLE_SHOW_GITHUB_FOR_USER` to hide the Header GitHub/version entry from normal users while keeping it visible to admin users (issue #1041) - feat: |Frontend| Upgrade the address credential dialog to "Address Credentials & Connection Methods" and reuse it for both normal users and admin-created addresses; support showing AI Agent access via `ENABLE_AGENT_EMAIL_INFO` and SMTP/IMAP client settings via `SMTP_IMAP_PROXY_CONFIG` - docs: |Random Subdomain| Clarify in the "Use Random Subdomain" frontend tip and the `subdomain` / `worker-vars` docs (zh & en) that receiving mail on `name@.abc.com` requires a wildcard `*` MX record under the base domain in DNS, because Cloudflare Email Routing does not inherit the apex configuration onto subdomains (issue #1035) diff --git a/e2e/tests/api/telegram-ai-extract.spec.ts b/e2e/tests/api/telegram-ai-extract.spec.ts new file mode 100644 index 00000000..0393e88b --- /dev/null +++ b/e2e/tests/api/telegram-ai-extract.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test'; +import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers'; + +test.describe('Telegram AI extraction rendering', () => { + test('realtime mail stores AI extraction metadata for Telegram rendering', async ({ request }) => { + const { jwt, address } = await createTestAddress(request, 'tg-ai'); + + try { + const subject = `Telegram AI realtime ${Date.now()}`; + const raw = [ + 'From: sender@test.example.com', + `To: ${address}`, + `Subject: ${subject}`, + `Message-ID: `, + 'MIME-Version: 1.0', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Telegram AI extraction realtime body', + ].join('\r\n'); + + const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, { + data: { + from: 'sender@test.example.com', + to: address, + raw, + ai_extract_result: { + type: 'auth_code', + result: '123456', + result_text: '', + }, + }, + }); + expect(receiveRes.ok()).toBe(true); + const receiveBody = await receiveRes.json(); + expect(receiveBody.success).toBe(true); + + const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(mailsRes.ok()).toBe(true); + const { results } = await mailsRes.json(); + expect(results).toHaveLength(1); + + const metadata = JSON.parse(results[0].metadata); + expect(metadata.ai_extract).toEqual({ + type: 'auth_code', + result: '123456', + result_text: '', + }); + expect(metadata.extracted_at).toBeTruthy(); + } finally { + await deleteAddress(request, jwt); + } + }); +}); diff --git a/worker/src/admin_api/e2e_test_api.ts b/worker/src/admin_api/e2e_test_api.ts index 4c9b859b..36ca7e9d 100644 --- a/worker/src/admin_api/e2e_test_api.ts +++ b/worker/src/admin_api/e2e_test_api.ts @@ -29,7 +29,7 @@ const receiveMail = async (c: Context) => { if (!getBooleanValue(c.env.E2E_TEST_MODE)) { return c.text("Not available", 404); } - const { from, to, raw } = await c.req.json(); + const { from, to, raw, ai_extract_result } = await c.req.json(); if (!from || !to || !raw) { return c.text("from, to and raw are required", 400); } @@ -54,7 +54,21 @@ const receiveMail = async (c: Context) => { reply: async () => { state.replyCalled = true; return { messageId: '' }; }, }; const { email: emailHandler } = await import('../email'); - await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} }); + const aiExtractEnvOverrides: Partial = { + ENABLE_AI_EMAIL_EXTRACT: true, + AI: { + run: async () => ({ response: ai_extract_result }) + } as unknown as Ai, + }; + const env = ai_extract_result + ? { ...c.env, ...aiExtractEnvOverrides } + : c.env; + const executionContext: ExecutionContext = { + waitUntil: () => {}, + passThroughOnException: () => {}, + props: {} + }; + await emailHandler(mockMessage, env, executionContext); return c.json({ success: !state.rejected, diff --git a/worker/src/email/ai_extract.ts b/worker/src/email/ai_extract.ts index 3b318963..bfc7c6f1 100644 --- a/worker/src/email/ai_extract.ts +++ b/worker/src/email/ai_extract.ts @@ -11,6 +11,7 @@ import { getBooleanValue, getJsonSetting } from "../utils"; import { CONSTANTS } from "../constants"; import { Context } from "hono"; import type { AiExtractSettings } from "../admin_api/ai_extract_settings"; +import type { ExtractResult } from "../models"; // AI Prompt for email analysis const PROMPT = ` @@ -144,7 +145,7 @@ async function extractWithCloudflareAI( * @param env - Cloudflare Workers environment bindings * @param message_id - The email message ID * @param address - The recipient email address - * @returns Promise + * @returns Promise */ export async function extractEmailInfo( parsedEmailContext: ParsedEmailContext, @@ -208,18 +209,21 @@ export async function extractEmailInfo( // If extraction found something useful, save it to database if (result.type !== 'none' && result.result) { - const metadata = JSON.stringify({ - ai_extract: result, - extracted_at: new Date().toISOString() - }); + try { + const metadata = JSON.stringify({ + ai_extract: result, + extracted_at: new Date().toISOString() + }); - // Update the raw_mails record with metadata - await env.DB.prepare( - `UPDATE raw_mails SET metadata = ? WHERE message_id = ?` - ).bind(metadata, message_id).run(); + // Update the raw_mails record with metadata + await env.DB.prepare( + `UPDATE raw_mails SET metadata = ? WHERE message_id = ?` + ).bind(metadata, message_id).run(); - console.log(`AI extraction completed for ${message_id}: ${result.type}`); - return result; + console.log(`AI extraction completed for ${message_id}: ${result.type}`); + } catch (e) { + console.error('AI extraction metadata save error:', e); + } } return result; } catch (e) { @@ -227,12 +231,3 @@ export async function extractEmailInfo( return null; } } - -/** - * Type definition for extraction result - */ -export type ExtractResult = { - type: 'auth_code' | 'auth_link' | 'service_link' | 'subscription_link' | 'other_link' | 'none'; - result: string; - result_text: string; -}; diff --git a/worker/src/models/index.ts b/worker/src/models/index.ts index baec2b97..329858b3 100644 --- a/worker/src/models/index.ts +++ b/worker/src/models/index.ts @@ -208,3 +208,9 @@ export type RawMailRow = { metadata?: string; created_at?: string; } + +export type ExtractResult = { + type: 'auth_code' | 'auth_link' | 'service_link' | 'subscription_link' | 'other_link' | 'none'; + result: string; + result_text: string; +} diff --git a/worker/src/telegram_api/telegram.ts b/worker/src/telegram_api/telegram.ts index 33b21438..dd3cff1d 100644 --- a/worker/src/telegram_api/telegram.ts +++ b/worker/src/telegram_api/telegram.ts @@ -10,11 +10,10 @@ import { sendTelegramAttachments } from "./tg_file_upload"; import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common"; import { commonParseMail } from "../common"; import { resolveRawEmail } from "../gzip"; -import { RawMailRow } from "../models"; import { UserFromGetMe } from "telegraf/types"; import i18n from "../i18n"; import { LocaleMessages } from "../i18n/type"; -import type { ExtractResult } from "../email/ai_extract"; +import type { ExtractResult, RawMailRow } from "../models"; // Helper to get messages by userId @@ -76,60 +75,54 @@ const COMMANDS = [ }, ] -const getAiExtractLabel = ( +const formatAiExtractForTelegram = ( msgs: LocaleMessages, - type: ExtractResult["type"] + aiExtract?: ExtractResult | string | null ): string => { - switch (type) { - case "auth_code": - return msgs.TgAiExtractAuthCodeMsg; - case "auth_link": - return msgs.TgAiExtractAuthLinkMsg; - case "service_link": - return msgs.TgAiExtractServiceLinkMsg; - case "subscription_link": - return msgs.TgAiExtractSubscriptionLinkMsg; - case "other_link": - return msgs.TgAiExtractOtherLinkMsg; - default: - return msgs.TgAiExtractResultMsg; + if (!aiExtract) { + return ""; } -} -const parseAiExtractMetadata = ( - metadata: string | undefined | null -): ExtractResult | null => { - if (!metadata) return null; try { - const parsed = JSON.parse(metadata); - const result = parsed?.ai_extract; - if ( - result - && typeof result.type === "string" - && result.type !== "none" - && typeof result.result === "string" - && result.result - ) { - return result as ExtractResult; + if (typeof aiExtract === "string") { + const metadata = JSON.parse(aiExtract); + aiExtract = metadata?.ai_extract; } } catch (error) { console.warn("Failed to parse AI extraction metadata", error); - } - return null; -} - -const formatAiExtractForTelegram = ( - msgs: LocaleMessages, - aiExtract: ExtractResult | null | undefined -): string => { - if (!aiExtract || aiExtract.type === "none" || !aiExtract.result) { return ""; } - const label = getAiExtractLabel(msgs, aiExtract.type); - const displayText = aiExtract.type !== "auth_code" && aiExtract.result_text - ? ` (${aiExtract.result_text})` + + if (!aiExtract || typeof aiExtract !== "object") { + return ""; + } + + const labels: Record, string> = { + auth_code: msgs.TgAiExtractAuthCodeMsg, + auth_link: msgs.TgAiExtractAuthLinkMsg, + service_link: msgs.TgAiExtractServiceLinkMsg, + subscription_link: msgs.TgAiExtractSubscriptionLinkMsg, + other_link: msgs.TgAiExtractOtherLinkMsg, + }; + const label = labels[aiExtract.type as keyof typeof labels]; + const result = typeof aiExtract.result === "string" + ? aiExtract.result.replace(/\s+/g, " ").trim().slice(0, 600) : ""; - return `${msgs.TgAiExtractResultMsg}\n${label}: ${aiExtract.result}${displayText}\n\n`; + if (!result) { + return ""; + } + + if (!label) { + return ""; + } + + const resultText = typeof aiExtract.result_text === "string" + ? aiExtract.result_text.replace(/\s+/g, " ").trim().slice(0, 120) + : ""; + const displayText = aiExtract.type !== "auth_code" && resultText && resultText !== result + ? ` (${resultText})` + : ""; + return `${msgs.TgAiExtractResultMsg}\n${label}: ${result}${displayText}\n\n`; } export const getTelegramCommands = (c: Context) => { @@ -369,9 +362,8 @@ export function newTelegramBot(c: Context, token: string): Teleg const raw = mailRow ? await resolveRawEmail(mailRow) : undefined; const mailId = mailRow?.id; const created_at = mailRow?.created_at; - const aiExtract = parseAiExtractMetadata(mailRow?.metadata); const { mail } = raw - ? await parseMail(msgs, { rawEmail: raw }, queryAddress, created_at, aiExtract) + ? await parseMail(msgs, { rawEmail: raw }, queryAddress, created_at, mailRow?.metadata) : { mail: msgs.TgNoMoreMailsMsg }; const settings = await c.env.KV.get(CONSTANTS.TG_KV_SETTINGS_KEY, "json"); const miniAppButtons = [] @@ -443,7 +435,7 @@ const parseMail = async ( parsedEmailContext: ParsedEmailContext, address: string, created_at: string | undefined | null, - aiExtract?: ExtractResult | null + aiExtract?: ExtractResult | string | null ) => { if (!parsedEmailContext.rawEmail) { return {};