mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-06-22 07:54:20 +08:00
fix: keep Telegram AI extract result on metadata errors (#1045)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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@<random>.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)
|
||||
|
||||
55
e2e/tests/api/telegram-ai-extract.spec.ts
Normal file
55
e2e/tests/api/telegram-ai-extract.spec.ts
Normal file
@@ -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: <telegram-ai-${Date.now()}@test>`,
|
||||
'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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
|
||||
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<HonoCustomType>) => {
|
||||
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
|
||||
};
|
||||
const { email: emailHandler } = await import('../email');
|
||||
await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} });
|
||||
const aiExtractEnvOverrides: Partial<Bindings> = {
|
||||
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,
|
||||
|
||||
@@ -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<void>
|
||||
* @returns Promise<ExtractResult | null>
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Exclude<ExtractResult["type"], "none">, 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<HonoCustomType>) => {
|
||||
@@ -369,9 +362,8 @@ export function newTelegramBot(c: Context<HonoCustomType>, 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<TelegramSettings>(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 {};
|
||||
|
||||
Reference in New Issue
Block a user