mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-17 13:24:13 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb0ed98c2 |
+1
-5
@@ -10,15 +10,11 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI 识别| 新增 `AI_EXTRACT_MODE`,可显式选择仅用本地规则(`local`)或优先用 Workers AI(`ai`)识别邮件;不填默认使用本地规则,邮件内容不会发送给 AI。**升级注意**:原先依赖 Workers AI 绑定自动启用 AI 识别的部署需设置 `AI_EXTRACT_MODE = "ai"`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |AI 识别| `ai` 模式下地址未命中 AI 提取白名单时只跳过 Workers AI 调用,仍回退到本地规则提取验证码
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |AI 识别| 本地验证码规则增强:同时识别邮件标题,支持验证码在关键词前(如 `116352(动态验证码)`、`ABC123 is your code`)、`G-123456` 前缀、分组 / 空格 / 零宽字符 / 全角数字,新增俄西葡法德意土希伯来语等关键词;排除超过 8 位数字、小数金额、时间、URL 与邮箱地址中的数字、tracking / order / voucher code 及纯字母单词,收紧无关键词时的数字识别,并限制分析长度、消除正则回溯风险
|
||||
- style: |Frontend| 将分栏邮件列表改为紧凑布局,压缩信息间距并省略长主题和地址,减少窄列表换行,为正文保留更多空间
|
||||
|
||||
## v1.12.0
|
||||
|
||||
|
||||
+1
-5
@@ -10,15 +10,11 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI Extract| Add `AI_EXTRACT_MODE` to explicitly choose local rules only (`local`) or prefer Workers AI (`ai`); defaults to local rules when unset so mail content is never sent to AI. **Upgrade note**: deployments that relied on the Workers AI binding to enable AI extraction automatically must set `AI_EXTRACT_MODE = "ai"`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |AI Extract| In `ai` mode, an address allowlist miss now skips only the Workers AI call and still falls back to local verification-code extraction
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |AI Extract| Improve local verification-code rules: also read the mail subject; support codes before keywords (e.g. `116352(动态验证码)`, `ABC123 is your code`), `G-123456` prefixes, grouped / spaced / zero-width-split / full-width codes, and Russian, Spanish, Portuguese, French, German, Italian, Turkish and Hebrew keywords; reject numbers longer than 8 digits, decimals and amounts, times, digits in URLs and email addresses, tracking / order / voucher codes and letters-only words; only accept keyword-less numbers in stricter positions; bound input length and remove regex backtracking risks
|
||||
- style: |Frontend| Compact the split-pane mail list with tighter spacing and truncated subjects and addresses, reducing wrapping in narrow lists and preserving space for message content
|
||||
|
||||
## v1.12.0
|
||||
|
||||
|
||||
@@ -9,7 +9,5 @@ COPY e2e/package.json e2e/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY e2e/ .
|
||||
# Dependency-free worker modules covered by unit tests in tests/unit/
|
||||
COPY worker/src/email/extract_code.ts worker/src/email/extract_mode.ts /app/worker/src/email/
|
||||
|
||||
ENTRYPOINT ["/app/e2e/scripts/docker-entrypoint.sh"]
|
||||
|
||||
@@ -31,8 +31,8 @@ const seedMail = async (request: Request, env: Bindings) => {
|
||||
|
||||
// Exercises the real email() handler with a mock ForwardableEmailMessage.
|
||||
const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => {
|
||||
const { from, to, raw, ai_extract_result, extract_mode } = await request.json<{
|
||||
from: string; to: string; raw: string; ai_extract_result?: unknown; extract_mode?: string;
|
||||
const { from, to, raw, ai_extract_result } = await request.json<{
|
||||
from: string; to: string; raw: string; ai_extract_result?: unknown;
|
||||
}>();
|
||||
if (!from || !to || !raw) {
|
||||
return new Response("from, to and raw are required", { status: 400 });
|
||||
@@ -60,12 +60,11 @@ const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContex
|
||||
const { email: emailHandler } = await import('../../worker/src/email');
|
||||
const aiExtractEnvOverrides: Partial<Bindings> = {
|
||||
ENABLE_AI_EMAIL_EXTRACT: true,
|
||||
AI_EXTRACT_MODE: extract_mode ?? 'ai',
|
||||
AI: {
|
||||
run: async () => ({ response: ai_extract_result })
|
||||
} as unknown as Ai,
|
||||
};
|
||||
const emailEnv = ai_extract_result || extract_mode !== undefined
|
||||
const emailEnv = ai_extract_result
|
||||
? { ...env, ...aiExtractEnvOverrides }
|
||||
: env;
|
||||
await emailHandler(mockMessage, emailEnv, ctx);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, WORKER_URL_ENV_OFF, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
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 }) => {
|
||||
@@ -54,209 +52,4 @@ test.describe('Telegram AI extraction rendering', () => {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('local extract mode uses built-in rules on subject and body, never calling AI', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'tg-local');
|
||||
|
||||
try {
|
||||
const raw = [
|
||||
'From: sender@test.example.com',
|
||||
`To: ${address}`,
|
||||
'Subject: G-482913 is your Google verification code',
|
||||
`Message-ID: <local-extract-${Date.now()}@test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Thanks for signing up. This message has no code in its body.',
|
||||
].join('\r\n');
|
||||
|
||||
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: {
|
||||
from: 'sender@test.example.com',
|
||||
to: address,
|
||||
raw,
|
||||
extract_mode: 'local',
|
||||
// The AI binding is still present; local mode must ignore this result.
|
||||
ai_extract_result: {
|
||||
type: 'auth_link',
|
||||
result: 'https://example.com/should-not-be-used',
|
||||
result_text: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(receiveRes.ok()).toBe(true);
|
||||
expect((await receiveRes.json()).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: '482913',
|
||||
result_text: '',
|
||||
});
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('ai allowlist only gates Workers AI and falls back to local rules on miss', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'tg-ai-local-fallback');
|
||||
|
||||
try {
|
||||
const offSettingsRes = await request.post(`${WORKER_URL}/admin/ai_extract/settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
enableAllowList: false,
|
||||
allowList: [],
|
||||
},
|
||||
});
|
||||
expect(offSettingsRes.ok()).toBe(true);
|
||||
|
||||
const aiRaw = [
|
||||
'From: sender@test.example.com',
|
||||
`To: ${address}`,
|
||||
'Subject: AI allowlist off',
|
||||
`Message-ID: <ai-allowlist-off-${Date.now()}@test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Your verification code is: 593817',
|
||||
].join('\r\n');
|
||||
|
||||
const aiReceiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: {
|
||||
from: 'sender@test.example.com',
|
||||
to: address,
|
||||
raw: aiRaw,
|
||||
extract_mode: 'ai',
|
||||
ai_extract_result: {
|
||||
type: 'auth_link',
|
||||
result: 'https://example.com/ai-used',
|
||||
result_text: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(aiReceiveRes.ok()).toBe(true);
|
||||
expect((await aiReceiveRes.json()).success).toBe(true);
|
||||
|
||||
const aiMailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(aiMailsRes.ok()).toBe(true);
|
||||
const aiMailsBody = await aiMailsRes.json();
|
||||
expect(aiMailsBody.results[0].metadata).toBeTruthy();
|
||||
expect(JSON.parse(aiMailsBody.results[0].metadata).ai_extract).toEqual({
|
||||
type: 'auth_link',
|
||||
result: 'https://example.com/ai-used',
|
||||
result_text: '',
|
||||
});
|
||||
|
||||
const onSettingsRes = await request.post(`${WORKER_URL}/admin/ai_extract/settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
enableAllowList: true,
|
||||
allowList: ['allowed@example.com'],
|
||||
},
|
||||
});
|
||||
expect(onSettingsRes.ok()).toBe(true);
|
||||
|
||||
const raw = [
|
||||
'From: sender@test.example.com',
|
||||
`To: ${address}`,
|
||||
'Subject: AI allowlist fallback',
|
||||
`Message-ID: <ai-allowlist-fallback-${Date.now()}@test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Your verification code is: 593817',
|
||||
].join('\r\n');
|
||||
|
||||
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: {
|
||||
from: 'sender@test.example.com',
|
||||
to: address,
|
||||
raw,
|
||||
extract_mode: 'ai',
|
||||
// If Workers AI were called, this would win. An allowlist miss must
|
||||
// skip AI and use local extraction instead.
|
||||
ai_extract_result: {
|
||||
type: 'auth_link',
|
||||
result: 'https://example.com/should-not-be-used',
|
||||
result_text: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(receiveRes.ok()).toBe(true);
|
||||
expect((await receiveRes.json()).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.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const metadata = JSON.parse(results[0].metadata);
|
||||
expect(metadata.ai_extract).toEqual({
|
||||
type: 'auth_code',
|
||||
result: '593817',
|
||||
result_text: '',
|
||||
});
|
||||
} finally {
|
||||
await request.post(`${WORKER_URL}/admin/ai_extract/settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
enableAllowList: false,
|
||||
allowList: [],
|
||||
},
|
||||
});
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('env-off worker keeps extraction disabled without test overrides', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'tg-extract-off', 'test.example.com', WORKER_URL_ENV_OFF);
|
||||
|
||||
try {
|
||||
const raw = [
|
||||
'From: sender@test.example.com',
|
||||
`To: ${address}`,
|
||||
'Subject: Extraction disabled',
|
||||
`Message-ID: <extract-off-${Date.now()}@test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Your verification code is: 374829',
|
||||
].join('\r\n');
|
||||
|
||||
const receiveRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, {
|
||||
data: {
|
||||
from: 'sender@test.example.com',
|
||||
to: address,
|
||||
raw,
|
||||
},
|
||||
});
|
||||
expect(receiveRes.ok()).toBe(true);
|
||||
expect((await receiveRes.json()).success).toBe(true);
|
||||
|
||||
const mailsRes = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const { results } = await mailsRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].metadata).toBeFalsy();
|
||||
} finally {
|
||||
const deleteRes = await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${address_id}`);
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { extractCode, joinSubjectAndBody } from '../../../worker/src/email/extract_code.ts';
|
||||
import { ExtractMode, resolveExtractMode } from '../../../worker/src/email/extract_mode.ts';
|
||||
|
||||
// Messages marked "2FHey" are adapted from https://github.com/SoFriendly/2fhey tests (CC0-1.0).
|
||||
const codeCases = [
|
||||
// Chinese
|
||||
['您的验证码是 123456,5分钟内有效', '123456'],
|
||||
['【某某】验证码:839201,请勿泄露', '839201'],
|
||||
['验证码123456,10分钟内有效', '123456'],
|
||||
['您的登录动态码为 4821', '4821'],
|
||||
['123456 是您的验证码,请勿转发', '123456'],
|
||||
['驗證碼:556677', '556677'],
|
||||
['【必胜客】116352(动态验证码),请在30分钟内填写', '116352'], // 2FHey
|
||||
['【APPLE】Apple ID代码为:724818。请勿与他人共享。', '724818'], // 2FHey
|
||||
['【某视频】654321短信登录验证码,5分钟内有效', '654321'],
|
||||
['校验码:123456,请勿告诉他人', '123456'],
|
||||
['您的动态口令为 918273', '918273'],
|
||||
['支付宝校验码 4096,付款金额 169.00 元', '4096'],
|
||||
// Japanese / Korean
|
||||
['認証コードは 482913 です。', '482913'],
|
||||
['ワンタイムパスコード「736251」を入力してください', '736251'],
|
||||
['인증번호 [736251]를 입력해 주세요.', '736251'],
|
||||
['인증 코드: 918273', '918273'],
|
||||
// Other languages
|
||||
['Ваш код: 123456', '123456'],
|
||||
['123-456 — код для входа', '123456'],
|
||||
['Su código de verificación es 482913', '482913'],
|
||||
['PayPal : 551234 est votre code de sécurité', '551234'],
|
||||
["Code d'authentification : AAAA1A", 'AAAA1A'],
|
||||
['Ihr Bestätigungscode ist: AB3C45', 'AB3C45'], // 2FHey
|
||||
['קוד האימות שלך הוא 123456', '123456'], // 2FHey
|
||||
// English
|
||||
['Your verification code is: 482913', '482913'],
|
||||
['123456 is your Instagram code', '123456'],
|
||||
['G-482913 is your Google verification code', '482913'],
|
||||
['ABC123 is your verification code', 'ABC123'], // 2FHey
|
||||
['Your code: 123-456', '123456'],
|
||||
['Your one-time passcode is 12 34 56', '123456'],
|
||||
['Your confirmation code is 8 4 9 2 0 1. Enter this to finish signing up.', '849201'],
|
||||
['Your security verification code is: 749183. Use it to complete sign-in.', '749183'],
|
||||
['Here is your one-time verification passcode: K9X-4B2. Valid for 10 minutes.', 'K9X4B2'],
|
||||
["Code is: RKJ-YP6 We'll NEVER call or text for this code.", 'RKJYP6'], // 2FHey
|
||||
['Your code is\n\n 839201\n\nIt expires in 10 minutes', '839201'],
|
||||
['Enter this code to sign in\n 591 204\n', '591204'],
|
||||
['Please enter the code below:\nAB12CD\n', 'AB12CD'],
|
||||
['Order #20391 shipped. Call 400-820-1234. Your login code: 7F3K9Q', '7F3K9Q'],
|
||||
['Use 4821 to verify your account', '4821'],
|
||||
['Please use SGD-123456 within 3 minutes to authorize this transaction.', '123456'], // 2FHey
|
||||
['Please verify your email.\n\n706215\n\nThanks', '706215'],
|
||||
['Please confirm your email address\n\n706215\n', '706215'],
|
||||
['Email verification\n\n582013\n\nThis code expires soon.', '582013'],
|
||||
['请验证您的邮箱\n\n662817\n', '662817'],
|
||||
['706215\n\nPlease verify your email address to continue.', '706215'],
|
||||
['[ 706215 ]\nPlease confirm your account', '706215'],
|
||||
['Hello,\n\nUse the following code to log in:\n\n 274019\n\nIf you did not request this, ignore.', '274019'],
|
||||
['Your Microsoft account security code\nSecurity code: 3721\nAccount: a***@x.com', '3721'],
|
||||
['Here is your GitHub launch code: 12345678\n\n© 2026 GitHub, Inc. San Francisco, CA 94107', '12345678'],
|
||||
['Your PIN is 4821', '4821'],
|
||||
['Your OTP for payment of Rs 5000 is 482913', '482913'], // 2FHey
|
||||
['OTP for txn of Rs 5000.00 is 482913', '482913'],
|
||||
['Your verification code for order 99887766 is 123456', '123456'],
|
||||
['This output contains a captcha with non-alphanumeric characters: ABCD123', 'ABCD123'], // 2FHey
|
||||
['Login code: 12345. Do not give this code to anyone', '12345'],
|
||||
['Your code for mark.kennedy.5561@example.com is 902113', '902113'],
|
||||
['123456 is OTP for your fund transfer, valid for 5 mins.', '123456'],
|
||||
['123456 ist dein Amazon-Einmalkennwort. Teile es nicht mit anderen Personen.', '123456'],
|
||||
['222222 ist der Google Pay Aktivierungscode für deine Karte.', '222222'],
|
||||
['Il tuo codice di sicurezza è: 123456', '123456'],
|
||||
['Le code à saisir pour votre achat de 200,00 EUR est 12345678.', '12345678'],
|
||||
['[Binance TR] Doğrulama Kodu: 123456. Lütfen paylaşmayın', '123456'],
|
||||
['【銀行轉帳】OTP密碼1234567,密碼勿告知他人', '1234567'],
|
||||
];
|
||||
|
||||
const noCodeCases = [
|
||||
'Hi, invoice total 3500 yuan, see attachment',
|
||||
'Your order #582910 has shipped',
|
||||
'Meeting moved to 2026-09-18 15:30, room 1203',
|
||||
'Use promo code: SAVE2026 at checkout',
|
||||
'Sign in to see your order, total $1299.00',
|
||||
'verification code is: Your account',
|
||||
'Your verification code is 2026',
|
||||
'Security notice for account created on 20260411',
|
||||
'Please verify your email by clicking the link below.\n© 2026 GitHub, Inc. San Francisco, CA 94107',
|
||||
'Confirm your subscription. Reply STOP to 10086',
|
||||
'Sign in attempt from 192.168.1.1 at 12:30. Ref 88213',
|
||||
'登录提醒:您的账号于 2026年9月17日 在北京登录,如非本人操作请致电 95588',
|
||||
'code: 4155552671', // 2FHey
|
||||
'Your verification code is 1234.56',
|
||||
'Your verification code is EXPIRED',
|
||||
'Do not share this code. Code: NEVER',
|
||||
'Tracking code: 58291034',
|
||||
'Order code: 582910',
|
||||
'Reference code: 48291',
|
||||
'Voucher code: 123456',
|
||||
'Sign in to your account\n\n90210\n',
|
||||
'Please verify. Account ID:\n884211\n',
|
||||
'Order confirmation\n123456',
|
||||
'706215\n\nThanks for your order',
|
||||
'Please verify your email. Account ID:\n\n884211\n',
|
||||
'Order confirmation\n\nOrder number\n582910\n',
|
||||
'Your payment is confirmed.\n\n482910\n',
|
||||
'Booking confirmed\n\n77120\n\nSee you soon',
|
||||
'Your purchase was verified\n\n558812\n',
|
||||
'Transaction authorized\n\n904411\n',
|
||||
'Ihre Bestellbestätigung\n\n448812\n',
|
||||
'Подтверждение заказа\n\n551203\n',
|
||||
'订单已确认\n\n772019\n',
|
||||
'Verify your email: https://example.com/verify?token=123456&id=998877',
|
||||
'Your footprint report for 2026: 58291 steps',
|
||||
'Welcome to our service. Enjoy a 30% discount with code SUMMER2026!',
|
||||
'Your order 5000 is the total, see the code of conduct',
|
||||
'We sent a code to your phone number ending 5678',
|
||||
'Your kod verification: see attached, amount 1,234 PLN',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const [text, expected] of codeCases) {
|
||||
test(`extracts ${expected} from ${JSON.stringify(text)}`, () => {
|
||||
assert.equal(extractCode(text), expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const text of noCodeCases) {
|
||||
test(`extracts nothing from ${JSON.stringify(text)}`, () => {
|
||||
assert.equal(extractCode(text), null);
|
||||
});
|
||||
}
|
||||
|
||||
test('subject and body are combined like the Worker does', () => {
|
||||
assert.equal(extractCode(joinSubjectAndBody('482913 is your Acme verification code', 'Hi, thanks for signing up.')), '482913');
|
||||
assert.equal(extractCode(joinSubjectAndBody(undefined, 'Your verification code: 551203')), '551203');
|
||||
assert.equal(extractCode(joinSubjectAndBody('Welcome', '')), null);
|
||||
});
|
||||
|
||||
test('a very long subject cannot push the body code out of the analyzed text', () => {
|
||||
const text = joinSubjectAndBody('x'.repeat(30000), 'Your verification code: 123456');
|
||||
assert.equal(extractCode(text), '123456');
|
||||
});
|
||||
|
||||
// Guards against catastrophic regex backtracking on large or hostile mails.
|
||||
const hostileInputs = {
|
||||
spaces: ' '.repeat(200000) + 'x',
|
||||
newlines: '\n'.repeat(200000) + 'verify',
|
||||
keywordAndSpaces: ('code' + ' '.repeat(1000)).repeat(200),
|
||||
letters: 'a'.repeat(200000) + ' code',
|
||||
digits: '1'.repeat(200000),
|
||||
atSigns: 'a'.repeat(100000) + '@' + 'b.'.repeat(50000),
|
||||
hyphens: 'code: ' + 'abc-'.repeat(50000),
|
||||
cjk: '验证码的的的的的的'.repeat(20000),
|
||||
};
|
||||
|
||||
for (const [name, text] of Object.entries(hostileInputs)) {
|
||||
test(`stays fast on hostile input: ${name}`, () => {
|
||||
const start = performance.now();
|
||||
extractCode(text);
|
||||
assert.ok(performance.now() - start < 500, `took ${performance.now() - start}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
test('extract mode defaults to local when unset', () => {
|
||||
assert.equal(resolveExtractMode(undefined), ExtractMode.Local);
|
||||
assert.equal(resolveExtractMode(''), ExtractMode.Local);
|
||||
assert.equal(resolveExtractMode(' '), ExtractMode.Local);
|
||||
});
|
||||
|
||||
test('extract mode accepts explicit ai and local', () => {
|
||||
assert.equal(resolveExtractMode('ai'), ExtractMode.Ai);
|
||||
assert.equal(resolveExtractMode(' AI '), ExtractMode.Ai);
|
||||
assert.equal(resolveExtractMode('local'), ExtractMode.Local);
|
||||
});
|
||||
|
||||
test('extract mode rejects unknown values', () => {
|
||||
assert.equal(resolveExtractMode('auto'), null);
|
||||
assert.equal(resolveExtractMode('regex'), null);
|
||||
assert.equal(resolveExtractMode(true), null);
|
||||
});
|
||||
@@ -425,34 +425,30 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<div class="mail-list-scroll mail-list-compact">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
<n-checkbox v-model:checked="row.checked" @click.stop />
|
||||
</template>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
<div class="mail-summary">
|
||||
<div class="mail-summary-header">
|
||||
<n-ellipsis class="mail-summary-sender">{{ row.source }}</n-ellipsis>
|
||||
<n-ellipsis class="mail-summary-date">
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
{{ showEMailTo ? "FROM: " + row.source : row.source }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
TO: {{ row.address }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<AiExtractInfo :metadata="row.metadata" compact />
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-ellipsis>
|
||||
</div>
|
||||
<div class="mail-summary-subject" :title="`ID: ${row.id}
|
||||
${row.subject || ''}`">
|
||||
<span v-if="enableMailReadStatus && row.is_unread === 1" class="mail-summary-unread-dot" />
|
||||
<n-ellipsis>{{ row.subject || '—' }}</n-ellipsis>
|
||||
</div>
|
||||
<n-ellipsis v-if="showEMailTo" class="mail-summary-recipient">
|
||||
TO: {{ row.address }}
|
||||
</n-ellipsis>
|
||||
<AiExtractInfo :metadata="row.metadata" compact />
|
||||
</div>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
@@ -664,6 +660,69 @@ onBeforeUnmount(() => {
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.mail-list-compact :deep(.n-list-item) {
|
||||
height: auto;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.mail-summary {
|
||||
min-width: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.mail-summary-header,
|
||||
.mail-summary-subject {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-summary-sender {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-summary-date {
|
||||
max-width: 48%;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.mail-summary-subject :deep(.n-ellipsis) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-summary-recipient {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.mail-list-unread .mail-summary-sender,
|
||||
.mail-list-unread .mail-summary-subject {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mail-summary-unread-dot {
|
||||
flex: 0 0 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #2080f0;
|
||||
}
|
||||
|
||||
.mail-summary :deep(.ai-extract-info) {
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.mail-summary :deep(.n-tag) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mail-summary :deep(.n-tag__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-list-thing,
|
||||
.mail-list-title,
|
||||
.mail-list-preview {
|
||||
|
||||
@@ -7,14 +7,7 @@
|
||||
|
||||
## Features
|
||||
|
||||
The email recognition feature automatically analyzes incoming email content and extracts important information. It supports two mutually exclusive modes:
|
||||
|
||||
| Mode | Extracts | Privacy | Requires |
|
||||
| ---- | -------- | ------- | -------- |
|
||||
| `local` (default) | **Verification codes** (auth_code) only | Built-in rules run inside the Worker; mail content is **never sent to any AI model** | Nothing, zero cost |
|
||||
| `ai` | Verification codes, auth links, service links, subscription links, other links | Mail content is sent to the Workers AI model in your Cloudflare account | Workers AI binding |
|
||||
|
||||
Types recognized in `ai` mode:
|
||||
The AI email recognition feature uses Cloudflare Workers AI to automatically analyze incoming email content and intelligently extract important information, including:
|
||||
|
||||
- **Verification Code** (auth_code) - OTP, security code, confirmation code, etc.
|
||||
- **Authentication Link** (auth_link) - Login, verify, activate, password reset links
|
||||
@@ -22,55 +15,26 @@ Types recognized in `ai` mode:
|
||||
- **Subscription Link** (subscription_link) - Unsubscribe, manage subscription links
|
||||
- **Other Link** (other_link) - Other valuable links
|
||||
|
||||
Extraction results are automatically saved to the `metadata` field in the database, the frontend can directly display extracted verification codes or links, and Telegram pushes and webhook placeholders reuse the same result.
|
||||
Extraction results are automatically saved to the `metadata` field in the database, and the frontend can directly display extracted verification codes or links.
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
| Variable Name | Type | Description | Example |
|
||||
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable email recognition (master switch, required by both modes) | `true` |
|
||||
| `AI_EXTRACT_MODE` | Text | Recognition mode: `local` uses built-in rules only, `ai` prefers Workers AI. Defaults to `local` when unset; any other value logs an error and skips recognition | `local` |
|
||||
| `AI_EXTRACT_MODEL` | Text | `ai` mode only. AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct-fast` |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable AI email recognition feature | `true` |
|
||||
| `AI_EXTRACT_MODEL` | Text | AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct-fast` |
|
||||
|
||||
> [!WARNING] Upgrading from older versions
|
||||
> Older versions automatically used AI recognition whenever a Workers AI binding was configured. Now, when `AI_EXTRACT_MODE` is unset, local rules are used by default. To keep using AI recognition, explicitly set `AI_EXTRACT_MODE = "ai"`.
|
||||
We recommend `@cf/meta/llama-3.1-8b-instruct-fast` as the default model because it supports the JSON Mode used by this feature, and Cloudflare says `-fast` variants will remain active. The cheaper `@cf/meta/llama-3.1-8b-instruct-fp8-fast` is not currently listed as a JSON Mode supported model, so it is not recommended for this feature. Cloudflare's newer recommended model `@cf/zai-org/glm-4.7-flash` is suitable for multilingual scenarios, but confirm structured JSON output support in your account/region before using it for this feature. The previous default model `@cf/meta/llama-3.1-8b-instruct` will be deprecated by Cloudflare on 2026-05-30 and is no longer recommended.
|
||||
|
||||
The two modes behave as follows:
|
||||
|
||||
- `local` mode never calls AI, even if a Workers AI binding is configured
|
||||
- `ai` mode logs an error and skips recognition for that mail when the Workers AI binding is missing or the model call fails; it does not switch to local rules
|
||||
- In `ai` mode, an address allowlist miss skips only the Workers AI call; local rules still run to try extracting verification codes
|
||||
|
||||
## Local Rule Mode (local)
|
||||
|
||||
- Extracts **verification codes** (`auth_code`) only; links are not extracted
|
||||
- Zero dependency, zero cost, runs locally inside the Worker; mail content never leaves the Worker
|
||||
- Reads both the **subject** and the body, so codes in the subject (e.g. `123456 is your verification code`) are extracted too
|
||||
- Supports common formats in Chinese, English, Japanese and Korean, plus Russian, Spanish, Portuguese, French, German, Italian, Turkish and Hebrew, e.g.:
|
||||
- Keyword first: `验证码:123456`, `Apple ID代码为:724818`, `認証コードは 123456 です`, `인증번호 [123456]`, `Ваш код: 123456`
|
||||
- Code first: `123456 是您的验证码`, `116352(动态验证码)`, `G-123456 is your Google verification code`, `123456 est votre code de sécurité`
|
||||
- Words between keyword and code: `Your OTP for payment of Rs 5000 is 482913`
|
||||
- Supports codes with separators, spaces, zero-width characters or full-width digits (e.g. `123-456`, `8 4 9 2 0 1`, `K9X-4B2`, `123456`); separators and letter prefixes such as `G-` are removed from the result
|
||||
- Alphanumeric codes must contain a digit; letters-only codes (e.g. `QGFDAE`) are not recognized, so words like `EXPIRED` are never taken as codes
|
||||
- Automatically rejects years and `YYYYMMDD` dates, numbers longer than 8 digits (e.g. phone numbers), decimals and amounts, times, digits inside URLs and email addresses, and promo / tracking / order / reference / voucher codes
|
||||
- Without an explicit keyword, a number is only recognized in a verification-looking mail when it is **on its own line** or right after "use / enter / 输入", so order numbers, hotlines and zip codes are not mistaken for codes
|
||||
- The subject (up to its first 1000 characters) and body are combined, and only the first 20000 characters of the combined text are analyzed, keeping CPU time predictable for large mails
|
||||
|
||||
## AI Mode (ai)
|
||||
|
||||
We recommend `@cf/meta/llama-3.1-8b-instruct-fast` as the default model because it supports the JSON Mode used by this feature, and Cloudflare says `-fast` variants will remain active. The cheaper `@cf/meta/llama-3.1-8b-instruct-fp8-fast` is not currently listed as a JSON Mode supported model, so it is not recommended for this feature. Cloudflare's newer recommended model `@cf/zai-org/glm-4.7-flash` is suitable for multilingual scenarios, but confirm structured JSON output support in your account/region before using it for this feature. The previous default model `@cf/meta/llama-3.1-8b-instruct` was deprecated by Cloudflare on 2026-05-30 and is no longer recommended.
|
||||
|
||||
### Content Length Limit
|
||||
## Content Length Limit
|
||||
|
||||
To avoid AI model token limits, the maximum email content length for processing is **4000 characters**. Email content exceeding this limit will be truncated before AI analysis.
|
||||
|
||||
### Workers AI Binding
|
||||
## Workers AI Binding
|
||||
|
||||
Configure Workers AI binding in `wrangler.toml`:
|
||||
|
||||
```toml
|
||||
AI_EXTRACT_MODE = "ai"
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
```
|
||||
@@ -79,14 +43,26 @@ Or add in Cloudflare Dashboard Worker settings:
|
||||
- **Variable name**: `AI`
|
||||
- **Type**: Workers AI
|
||||
|
||||
## Fallback Without a Workers AI Binding
|
||||
|
||||
If `ENABLE_AI_EMAIL_EXTRACT` is enabled but **no Workers AI binding is configured** (e.g. a self-hosted deployment without Workers AI), the system automatically falls back to a built-in **regex verification-code extractor**:
|
||||
|
||||
- Extracts **verification codes** (`auth_code`) only; links are not extracted (link extraction requires AI)
|
||||
- Zero dependency, zero cost, runs locally inside the Worker
|
||||
- Supports common verification-code formats in English, Chinese, Japanese and Korean
|
||||
- Rejects years (e.g. `2026`) and `YYYYMMDD` dates to reduce false positives
|
||||
- Results are written to `metadata` and reuse the same Telegram / webhook placeholders (`aiExtractType` is `auth_code` in this case)
|
||||
|
||||
When a Workers AI binding is configured, AI extraction is still preferred (recognizing both codes and links) and this fallback does not apply.
|
||||
|
||||
## Address Allowlist (Optional)
|
||||
|
||||
To control costs and resource usage, you can configure an address allowlist in the Admin console's **AI Extract Settings** page. The allowlist controls only Workers AI calls, not local rule mode; in `ai` mode, addresses outside the allowlist still use local rules to try extracting verification codes.
|
||||
To control costs and resource usage, you can configure an address allowlist in the Admin console's **AI Extract Settings** page:
|
||||
|
||||
### Configuration
|
||||
|
||||
- **Allowlist Disabled**: Workers AI extraction can process all email addresses
|
||||
- **Allowlist Enabled**: Workers AI is called only for addresses in the allowlist; addresses outside it skip Workers AI and fall back to local verification-code extraction
|
||||
- **Allowlist Disabled**: AI extraction will process all email addresses
|
||||
- **Allowlist Enabled**: AI extraction will only process addresses in the allowlist
|
||||
|
||||
### Allowlist Format
|
||||
|
||||
@@ -106,7 +82,7 @@ user@example.com
|
||||
admin*@company.com
|
||||
```
|
||||
|
||||
This configuration will only call Workers AI for:
|
||||
This configuration will only perform AI extraction for:
|
||||
- `user@example.com` (exact match)
|
||||
- All emails under `@mydomain.com` (e.g., `test@mydomain.com`, `admin@mydomain.com`)
|
||||
- All emails starting with `admin` under `@company.com` (e.g., `admin@company.com`, `admin123@company.com`)
|
||||
|
||||
@@ -7,14 +7,7 @@
|
||||
|
||||
## 功能说明
|
||||
|
||||
邮件识别功能会自动分析收到的邮件内容,提取其中的重要信息,并支持两种互斥的识别模式:
|
||||
|
||||
| 模式 | 识别内容 | 隐私 | 依赖 |
|
||||
| ---- | -------- | ---- | ---- |
|
||||
| `local`(默认) | 仅**验证码** (auth_code) | 在 Worker 内用内置规则识别,邮件内容**不会发送给任何 AI 模型** | 无,零成本 |
|
||||
| `ai` | 验证码、认证链接、服务链接、订阅管理链接、其他链接 | 邮件内容会发送给你 Cloudflare 账号下的 Workers AI 模型 | Workers AI 绑定 |
|
||||
|
||||
`ai` 模式可识别的类型:
|
||||
AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内容,智能提取重要信息,包括:
|
||||
|
||||
- **验证码** (auth_code) - OTP、安全码、确认码等
|
||||
- **认证链接** (auth_link) - 登录、验证、激活、重置密码链接
|
||||
@@ -22,55 +15,26 @@
|
||||
- **订阅管理链接** (subscription_link) - 退订、管理订阅等链接
|
||||
- **其他链接** (other_link) - 其他有价值的链接
|
||||
|
||||
提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接,Telegram 推送与 Webhook 占位符也会复用该结果。
|
||||
提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接。
|
||||
|
||||
## 配置变量
|
||||
|
||||
| 变量名 | 类型 | 说明 | 示例 |
|
||||
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用邮件识别功能(总开关,两种模式都需要) | `true` |
|
||||
| `AI_EXTRACT_MODE` | 文本 | 识别模式:`local` 仅用内置规则,`ai` 优先使用 Workers AI。不填默认为 `local`,填写其他值会记录错误日志并跳过识别 | `local` |
|
||||
| `AI_EXTRACT_MODEL` | 文本 | 仅 `ai` 模式生效。AI 模型名称,从[支持 JSON 模式的模型](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models)中选择 | `@cf/meta/llama-3.1-8b-instruct-fast` |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用 AI 邮件识别功能 | `true` |
|
||||
| `AI_EXTRACT_MODEL` | 文本 | AI 模型名称,从[支持 JSON 模式的模型](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models)中选择 | `@cf/meta/llama-3.1-8b-instruct-fast` |
|
||||
|
||||
> [!WARNING] 从旧版本升级
|
||||
> 旧版本在配置了 Workers AI 绑定时会自动使用 AI 识别。现在不填 `AI_EXTRACT_MODE` 时默认使用本地规则,如需继续使用 AI 识别,请显式设置 `AI_EXTRACT_MODE = "ai"`。
|
||||
推荐使用 `@cf/meta/llama-3.1-8b-instruct-fast` 作为默认模型,它支持当前实现依赖的 JSON Mode,且 Cloudflare 说明 `-fast` 变体会保持可用。价格更低的 `@cf/meta/llama-3.1-8b-instruct-fp8-fast` 目前不在 JSON Mode 支持列表中,不建议用于本功能。Cloudflare 推荐的新模型 `@cf/zai-org/glm-4.7-flash` 适合多语言场景,但用于本功能前请先确认它在你的账号/区域支持结构化 JSON 输出。旧默认模型 `@cf/meta/llama-3.1-8b-instruct` 将于 2026-05-30 被 Cloudflare 弃用,不建议继续使用。
|
||||
|
||||
两种模式的主要行为:
|
||||
|
||||
- `local` 模式即使配置了 Workers AI 绑定,也不会调用 AI
|
||||
- `ai` 模式未配置 Workers AI 绑定或调用模型失败时,会记录错误日志并跳过本封邮件的识别,不会改用本地规则
|
||||
- `ai` 模式下如果地址未命中 AI 提取白名单,只会跳过 Workers AI 调用,仍会改用本地规则尝试提取验证码
|
||||
|
||||
## 本地规则模式(local)
|
||||
|
||||
- 仅提取**验证码**(`auth_code`),不提取链接
|
||||
- 零依赖、零成本,在 Worker 内本地完成,邮件内容不会离开 Worker
|
||||
- 同时识别**邮件标题**和正文,标题中的验证码(如 `123456 is your verification code`)也能提取
|
||||
- 支持中文、英文、日文、韩文,以及俄语、西班牙语、葡萄牙语、法语、德语、意大利语、土耳其语、希伯来语的常见写法,例如:
|
||||
- 关键词在前:`验证码:123456`、`Apple ID代码为:724818`、`認証コードは 123456 です`、`인증번호 [123456]`、`Ваш код: 123456`
|
||||
- 验证码在前:`123456 是您的验证码`、`116352(动态验证码)`、`G-123456 is your Google verification code`、`123456 est votre code de sécurité`
|
||||
- 关键词与验证码之间有其他词:`Your OTP for payment of Rs 5000 is 482913`
|
||||
- 支持带分隔符、空格、零宽字符或全角数字的验证码(如 `123-456`、`8 4 9 2 0 1`、`K9X-4B2`、`123456`),结果会去掉分隔符和 `G-` 这类字母前缀
|
||||
- 字母数字混合的验证码必须包含数字,纯字母验证码(如 `QGFDAE`)不会识别,以免把 `EXPIRED` 这类单词误判为验证码
|
||||
- 自动排除:年份与 `YYYYMMDD` 日期、超过 8 位的数字(如电话号码)、小数与金额、时间、URL 和邮箱地址中的数字,以及 promo / tracking / order / reference / voucher code 等非验证码
|
||||
- 没有明确关键词时,只有在验证类邮件中、且数字**单独成行**或紧跟「输入 / use / enter」时才会识别,避免把订单号、客服电话、邮编误判为验证码
|
||||
- 标题最多取前 1000 个字符,与正文合并后只分析前 20000 个字符,保证大邮件的 CPU 耗时可控
|
||||
|
||||
## AI 模式(ai)
|
||||
|
||||
推荐使用 `@cf/meta/llama-3.1-8b-instruct-fast` 作为默认模型,它支持当前实现依赖的 JSON Mode,且 Cloudflare 说明 `-fast` 变体会保持可用。价格更低的 `@cf/meta/llama-3.1-8b-instruct-fp8-fast` 目前不在 JSON Mode 支持列表中,不建议用于本功能。Cloudflare 推荐的新模型 `@cf/zai-org/glm-4.7-flash` 适合多语言场景,但用于本功能前请先确认它在你的账号/区域支持结构化 JSON 输出。旧默认模型 `@cf/meta/llama-3.1-8b-instruct` 已于 2026-05-30 被 Cloudflare 弃用,不建议继续使用。
|
||||
|
||||
### 内容长度限制
|
||||
## 内容长度限制
|
||||
|
||||
为避免 AI 模型 token 限制,邮件内容最大处理长度为 **4000 字符**。超过此长度的邮件内容将被截断后再进行 AI 分析。
|
||||
|
||||
### Workers AI 绑定
|
||||
## Workers AI 绑定
|
||||
|
||||
需要在 `wrangler.toml` 中配置 Workers AI 绑定:
|
||||
|
||||
```toml
|
||||
AI_EXTRACT_MODE = "ai"
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
```
|
||||
@@ -79,14 +43,26 @@ binding = "AI"
|
||||
- **Variable name**: `AI`
|
||||
- **Type**: Workers AI
|
||||
|
||||
## 无 Workers AI 绑定时的正则兜底
|
||||
|
||||
如果启用了 `ENABLE_AI_EMAIL_EXTRACT` 但**没有配置 Workers AI 绑定**(例如自部署时未开通 Workers AI),系统会自动回退到内置的**正则验证码提取**:
|
||||
|
||||
- 仅提取**验证码**(`auth_code`),不提取链接(链接提取依赖 AI)
|
||||
- 零依赖、零成本,在 Worker 内本地完成
|
||||
- 支持中文、英文、日文、韩文常见验证码格式
|
||||
- 自动排除年份(如 `2026`)与 `YYYYMMDD` 日期,降低误判
|
||||
- 提取结果同样写入 `metadata`,并复用 Telegram 推送与 Webhook 占位符(此时 `aiExtractType` 为 `auth_code`)
|
||||
|
||||
当配置了 Workers AI 绑定时,仍优先使用 AI 提取(可识别验证码与各类链接),不受此回退影响。
|
||||
|
||||
## 地址白名单(可选)
|
||||
|
||||
为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单。白名单只控制 Workers AI 调用,不限制本地规则模式;`ai` 模式下未命中白名单的地址仍会使用本地规则尝试提取验证码。
|
||||
为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单:
|
||||
|
||||
### 配置说明
|
||||
|
||||
- **未启用白名单**:所有邮箱地址都可使用 Workers AI 提取
|
||||
- **启用白名单**:仅白名单中的邮箱地址会调用 Workers AI;未命中的地址会跳过 Workers AI,并回退到本地验证码提取
|
||||
- **未启用白名单**:所有邮箱地址都可使用 AI 提取功能
|
||||
- **启用白名单**:仅白名单中的邮箱地址会进行 AI 提取
|
||||
|
||||
### 白名单格式
|
||||
|
||||
@@ -106,7 +82,7 @@ user@example.com
|
||||
admin*@company.com
|
||||
```
|
||||
|
||||
此配置将只对以下邮箱调用 Workers AI:
|
||||
此配置将只对以下邮箱进行 AI 提取:
|
||||
- `user@example.com`(精确匹配)
|
||||
- 所有 `@mydomain.com` 的邮箱(如 `test@mydomain.com`、`admin@mydomain.com`)
|
||||
- 所有 `admin` 开头的 `@company.com` 邮箱(如 `admin@company.com`、`admin123@company.com`)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* AI Email Extraction Module
|
||||
*
|
||||
* This module provides email content analysis, either with built-in local rules
|
||||
* (verification codes only) or with Cloudflare Workers AI, which also extracts
|
||||
* authentication links, service links, and subscription management links.
|
||||
* This module provides email content analysis using Cloudflare Workers AI.
|
||||
* It extracts important information like verification codes, authentication links,
|
||||
* service links, and subscription management links from email content.
|
||||
*/
|
||||
|
||||
import { commonParseMail } from "../common";
|
||||
import { extractCode, joinSubjectAndBody } from "./extract_code";
|
||||
import { ExtractMode, resolveExtractMode } from "./extract_mode";
|
||||
import { extractCode } from "./extract_code";
|
||||
import { getBooleanValue, getJsonSetting } from "../utils";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { Context } from "hono";
|
||||
@@ -142,7 +141,7 @@ async function extractWithCloudflareAI(
|
||||
|
||||
/**
|
||||
* Persist an extraction result to the raw_mails metadata column.
|
||||
* Shared by the Workers AI mode and the local rule mode.
|
||||
* Shared by the Workers AI path and the regex fallback path.
|
||||
*
|
||||
* @param env - Cloudflare Workers environment bindings
|
||||
* @param message_id - The email message ID
|
||||
@@ -227,24 +226,11 @@ function getEmailContentForExtract(parsedEmail: Awaited<ReturnType<typeof common
|
||||
return htmlToTextForAi(parsedEmail.html) || parsedEmail.html;
|
||||
}
|
||||
|
||||
function isAddressInAiAllowlist(settings: AiExtractSettings | null | undefined, address: string): boolean {
|
||||
if (!settings?.enableAllowList || !settings.allowList?.length) return true;
|
||||
return settings.allowList.some(pattern => {
|
||||
if (!pattern.includes('*')) return address === pattern;
|
||||
const escapedPattern = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
return new RegExp('^' + escapedPattern + '$').test(address);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main extraction function
|
||||
* Checks if extraction is enabled, processes the email content, and saves to database.
|
||||
* `AI_EXTRACT_MODE` selects the preferred extractor:
|
||||
* - `local` (default): built-in rules, verification codes only, content never sent to AI
|
||||
* - `ai`: Cloudflare Workers AI, verification codes and links; if the address is not
|
||||
* in the AI allowlist, only the AI call is skipped and local code extraction still runs
|
||||
* Uses Cloudflare Workers AI when the `AI` binding is available; otherwise falls back
|
||||
* to a built-in regex extractor that surfaces verification codes only.
|
||||
*
|
||||
* @param parsedEmailContext - The parsed email context
|
||||
* @param env - Cloudflare Workers environment bindings
|
||||
@@ -264,52 +250,55 @@ export async function extractEmailInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = resolveExtractMode(env.AI_EXTRACT_MODE);
|
||||
if (!mode) {
|
||||
console.error(`Email extraction skipped: unsupported AI_EXTRACT_MODE "${env.AI_EXTRACT_MODE}", expected "local" or "ai"`);
|
||||
return null;
|
||||
}
|
||||
// Check allowlist if enabled (applies to both AI and the regex fallback)
|
||||
const aiSettings = await getJsonSetting<AiExtractSettings>(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
CONSTANTS.AI_EXTRACT_SETTINGS_KEY
|
||||
);
|
||||
const isAiAllowed = isAddressInAiAllowlist(aiSettings, address);
|
||||
|
||||
// Parse email to get content (shared by both modes)
|
||||
if (aiSettings?.enableAllowList && aiSettings.allowList?.length > 0) {
|
||||
const isAllowed = aiSettings.allowList.some(pattern => {
|
||||
// Support wildcard matching
|
||||
if (pattern.includes('*')) {
|
||||
// Escape special regex characters except *
|
||||
const escapedPattern = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
const regex = new RegExp('^' + escapedPattern + '$');
|
||||
return regex.test(address);
|
||||
}
|
||||
// Exact match
|
||||
return address === pattern;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
console.log(`Email extraction skipped for ${address}: not in allowlist`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse email to get content (shared by the AI path and the regex fallback)
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const emailContent = getEmailContentForExtract(parsedEmail);
|
||||
|
||||
const runLocalExtract = async () => {
|
||||
const localContent = joinSubjectAndBody(parsedEmail?.subject, emailContent);
|
||||
const code = localContent ? extractCode(localContent) : null;
|
||||
if (!code) return null;
|
||||
const result: ExtractResult = { type: 'auth_code', result: code, result_text: '' };
|
||||
await saveExtractMetadata(env, message_id, result);
|
||||
console.log(`Local code extraction completed for ${message_id}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Local mode: built-in rules only, mail content is never sent to any AI model.
|
||||
// The subject is included because many services put the code there.
|
||||
// Telegram / webhook reuse the same ExtractResult.
|
||||
if (mode === ExtractMode.Local) {
|
||||
return await runLocalExtract();
|
||||
}
|
||||
|
||||
if (!isAiAllowed) {
|
||||
console.log(`Workers AI extraction skipped for ${address}: not in AI allowlist; trying local code extraction`);
|
||||
return await runLocalExtract();
|
||||
}
|
||||
|
||||
if (!env.AI) {
|
||||
console.error('Email extraction skipped: AI_EXTRACT_MODE is "ai" but the Workers AI binding "AI" is not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!emailContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallback: when no Workers AI binding is available, use a built-in regex
|
||||
// extractor so self-hosted deployments without Workers AI still surface
|
||||
// verification codes. Telegram / webhook reuse the same ExtractResult.
|
||||
if (!env.AI) {
|
||||
const code = extractCode(emailContent);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
const result: ExtractResult = { type: 'auth_code', result: code, result_text: '' };
|
||||
await saveExtractMetadata(env, message_id, result);
|
||||
console.log(`Regex code extraction completed for ${message_id}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Truncate content if too long (max 4000 characters to avoid token limits)
|
||||
const truncatedContent = emailContent.length > 4000
|
||||
? emailContent.substring(0, 4000) + '...[truncated]'
|
||||
|
||||
@@ -1,205 +1,61 @@
|
||||
/**
|
||||
* Local rule-based verification code extraction.
|
||||
* Regex-based verification code extraction.
|
||||
*
|
||||
* Extracts verification codes from email text without calling any AI model,
|
||||
* so mail content never leaves the regex engine inside the Worker.
|
||||
* Extracts verification codes (4-12 digit / alphanumeric) from email text.
|
||||
* Covers common formats across English, Chinese, Japanese and Korean.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Normalize: strip zero-width characters, fold full-width digits and letters,
|
||||
* and remove URLs / email addresses so their digits are never candidates.
|
||||
* 2. Try keyword-anchored patterns in priority order (keyword before code,
|
||||
* code before keyword, keyword on the previous line).
|
||||
* 3. Only for verification-looking mails, fall back to a code on its own line
|
||||
* or right after "use / enter / 输入".
|
||||
* This is a zero-dependency fallback used when Workers AI is not bound, so
|
||||
* self-hosted deployments without Workers AI still surface verification codes.
|
||||
*
|
||||
* Every candidate must look like a code: 4-8 digits (not a year or YYYYMMDD
|
||||
* date), or 4-10 letters and digits containing at least one digit. Digits that
|
||||
* belong to amounts, decimals, times, phone numbers or longer numbers are never
|
||||
* taken as codes.
|
||||
*
|
||||
* Some keyword lists and test messages are adapted from 2FHey
|
||||
* (https://github.com/SoFriendly/2fhey, CC0-1.0).
|
||||
* Design principles (learned from QA):
|
||||
* 1. Prefer codes that appear after an explicit code keyword.
|
||||
* 2. Reject plausible dates/years (e.g. "2026", "20260411") — too common as
|
||||
* date markers in subject lines, almost never a real verification code.
|
||||
* 3. Allow multi-character delimiters (a keyword followed by a colon or "is").
|
||||
* 4. Fall back to standalone digits ONLY if no keyword-guided match found
|
||||
* AND the digits don't look like a year or YYYYMMDD date.
|
||||
*/
|
||||
|
||||
const KEYWORD_LIST = [
|
||||
// Chinese
|
||||
'验证码', '驗證碼', '校验码', '校驗碼', '认证码', '認證碼', '确认码', '確認碼', '动态码', '動態碼',
|
||||
'动态密码', '動態密碼', '动态口令', '短信口令', '安全码', '安全代码', '登录码', '登入码',
|
||||
'激活码', '一次性密码', '校验代码', '识别码', '随机码', '交易码', '(?<!源)代码',
|
||||
'(?:\\bOTP|动态|動態|一次性)\\s{0,3}密[码碼]',
|
||||
// Japanese
|
||||
'認証コード', '確認コード', '認証番号', '確認番号', 'ワンタイムパスワード', 'ワンタイムパスコード',
|
||||
'パスコード', 'セキュリティコード', '確認用コード',
|
||||
// Korean
|
||||
'인증\\s{0,3}코드', '인증\\s{0,3}번호', '확인\\s{0,3}코드', '보안\\s{0,3}코드',
|
||||
// English
|
||||
'verification\\s{0,3}code', 'verify\\s{0,3}code', 'confirm(?:ation)?\\s{0,3}code', 'security\\s{0,3}code',
|
||||
'log[\\s-]?in\\s{0,3}code', 'sign[\\s-]?in\\s{0,3}code', 'access\\s{0,3}code', 'auth(?:entication|orization)?\\s{0,3}code',
|
||||
'activation\\s{0,3}code', 'validation\\s{0,3}code', 'two[\\s-]?factor\\s{0,3}code', '2FA\\s{0,3}code',
|
||||
'one[\\s-]?time\\s{0,3}(?:pass(?:word|code)|code|pin)', '\\bpasscode', '\\bOTP\\b', '\\bPIN\\b', '\\bcaptcha',
|
||||
// Spanish / Portuguese
|
||||
'c[óo]digo(?!\\s{1,3}postal)(?:\\s{1,3}de\\s{1,3}(?:verificaci[óo]n|verifica[çc][ãa]o|seguridad|seguran[çc]a|acceso|acesso|confirmaci[óo]n|confirma[çc][ãa]o))?',
|
||||
// Italian
|
||||
'codice(?:\\s{1,3}di\\s{1,3}(?:sicurezza|verifica|conferma|accesso))?',
|
||||
// Turkish / Polish
|
||||
'(?<!\\p{L})kod(?:u|y)?(?!\\p{L})',
|
||||
// French
|
||||
'code\\s{1,3}(?:de\\s{1,3}(?:s[ée]curit[ée]|v[ée]rification|confirmation|connexion)|d[\'’](?:authentification|acc[èe]s|activation))',
|
||||
// German
|
||||
'(?:best[äa]tigungs|verifizierungs|sicherheits|anmelde|einmal|aktivierungs)code', 'einmalkennwort',
|
||||
// Russian / Ukrainian
|
||||
'(?<!\\p{L})код(?:\\s{1,3}подтверждения)?(?!\\p{L})',
|
||||
// Hebrew
|
||||
'קוד(?:\\s{1,3}(?:האימות|אימות))?',
|
||||
// Bare "code", excluding codes that are not verification codes
|
||||
// (the lookbehind runs after matching "code", so long runs of whitespace are never rescanned)
|
||||
'\\bcode\\b(?<!(?:promo|promotion|promotional|coupon|discount|gift|referral|invite|invitation|zip|postal|post|country|area|source|error|status|tracking|order|reference|ref|voucher|product|item|booking|qr|bar|redeem|redemption|html|sample)[\\s-]{0,3}code)',
|
||||
];
|
||||
const KW = `(?:${KEYWORD_LIST.join('|')})`;
|
||||
// CJK keywords, used by the "123456(登录验证码)" pattern where no spaces separate words.
|
||||
const CJK_KW = `(?:${KEYWORD_LIST.filter(k => /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(k)).join('|')})`;
|
||||
|
||||
// Delimiter between keyword and code: colon, dash, "is" in several languages, or a CJK particle.
|
||||
// \b is ASCII-only, so word boundaries use \p{L} lookarounds to also work for "é" / "è".
|
||||
const DELIM = '\\s{0,3}(?:[::=—–]|(?<!\\p{L})(?:is|was|ist|est|es|é|è)(?!\\p{L})|是|为|為|は|는|은|です|הוא)[\\s::]{0,8}';
|
||||
// Optional opening/closing bracket or quote around the code.
|
||||
const OPEN = '[\\[【((「『"\'“]?\\s{0,3}';
|
||||
const CLOSE = '\\s{0,3}[\\]】))」』"\'”]?';
|
||||
|
||||
// Not part of a larger number, amount, decimal, time or phone number.
|
||||
const NUM_BEFORE = '(?<![\\w+\\-/])(?<!\\d[.,::])(?<![$€£¥₹]\\s?)(?<!\\bRs\\.?\\s?)';
|
||||
const NUM_AFTER = '(?![\\w/%]|[ -]\\d|[.,:]\\d|\\s?(?:USD|EUR|GBP|RMB|CNY|元|円|원))';
|
||||
// Digit codes, optionally grouped (123-456, 591 204, 12 34 56, 8 4 9 2 0 1) or letter-prefixed (G-482913).
|
||||
const DIGIT_CODE = `${NUM_BEFORE}(?:[A-Z]{1,3}-)?`
|
||||
+ '(\\d{3}[ -]\\d{3}|\\d{4}[ -]\\d{4}|\\d{2}[ -]\\d{2}[ -]\\d{2}|\\d(?: \\d){3,7}|\\d{4,8})'
|
||||
+ NUM_AFTER;
|
||||
// Alphanumeric codes such as 7F3K9Q or K9X-4B2.
|
||||
const ALNUM_CODE = '(?<![\\w\\-+/])([A-Za-z0-9]{3,5}-[A-Za-z0-9]{3,5}|[A-Za-z0-9]{4,10})(?![\\w\\-/]| \\d|[.,:]\\d)';
|
||||
const ANY_CODE = `(?:${DIGIT_CODE}|${ALNUM_CODE})`;
|
||||
// Words allowed between keyword and delimiter, e.g. "OTP for payment of Rs 5000 is".
|
||||
// Tokens may not end with sentence punctuation, so the filler stays inside one sentence.
|
||||
const FILLER = '(?:\\s{1,3}[^\\s。!?!?]{0,40}[^\\s。!?!?.,:])(?:\\s{1,3}[^\\s。!?!?]{0,40}[^\\s。!?!?.,:]){0,8}?';
|
||||
// Words allowed between "123456 is your" and the keyword, e.g. "Google".
|
||||
const NAME_FILLER = '(?:[\\p{L}\\d.\'’&-]{1,40}\\s{1,3}){0,4}';
|
||||
|
||||
const PATTERNS: RegExp[] = [
|
||||
// "verification code: 123456" / "验证码是 123-456" / "認証コードは 123456 です"
|
||||
new RegExp(`${KW}${DELIM}${OPEN}${DIGIT_CODE}${CLOSE}`, 'giu'),
|
||||
// "login code: 7F3K9Q" / "passcode: K9X-4B2"
|
||||
new RegExp(`${KW}${DELIM}${OPEN}${ALNUM_CODE}`, 'giu'),
|
||||
// "123456 is your Instagram code" / "ABC123 is your verification code" / "123456 est votre code de sécurité"
|
||||
// "123456 is OTP for your transfer" / "123456 ist dein Amazon-Einmalkennwort"
|
||||
new RegExp(`${ANY_CODE}\\s{1,3}(?:is|are|est|ist|es|é|è)\\s{1,3}(?:(?:your|the|votre|ihr|dein|deine|der|die|das|su|tu|il\\s{1,3}tuo|seu|o\\s{1,3}seu)\\s{1,3})?${NAME_FILLER}(?:\\p{L}{1,20}-)?${KW}`, 'giu'),
|
||||
// "123456 是您的验证码" / "116352(动态验证码)" / "123456短信登录验证码"
|
||||
new RegExp(`${ANY_CODE}\\s{0,3}[((【\\[]?\\s{0,3}(?:是|为|為|は)?\\s{0,3}(?:您|你)?的?[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}]{0,6}?${CJK_KW}`, 'giu'),
|
||||
// "123-456 — код для входа"
|
||||
new RegExp(`${ANY_CODE}\\s{0,3}[—–]\\s{0,3}${KW}`, 'giu'),
|
||||
// "OTP for payment of Rs 5000 is 482913" / "קוד האימות שלך הוא 123456"
|
||||
new RegExp(`${KW}${FILLER}${DELIM}${OPEN}${ANY_CODE}`, 'giu'),
|
||||
// "验证码123456" / "인증번호 [736251]" / "ワンタイムパスコード「123456」" (no delimiter, digits only)
|
||||
new RegExp(`${KW}\\s{0,3}${OPEN}${DIGIT_CODE}`, 'giu'),
|
||||
// "Enter this code to sign in\n 591 204" / "enter the code below:\nAB12CD" — code on its own line
|
||||
new RegExp(`${KW}[^\\n\\d]{0,60}\\n\\s{0,8}${OPEN}${ANY_CODE}${CLOSE}[ \\t]{0,8}(?:\\n|$)`, 'giu'),
|
||||
];
|
||||
|
||||
// Phrases showing the mail asks the recipient to verify something; gate for the fallback patterns.
|
||||
// Single words like "confirm", "verified" or "sign in" are deliberately not enough: they also
|
||||
// appear in order confirmations, payment notices and most mail footers.
|
||||
const VERIFY_TARGET = '(?:e-?mail(?:\\s{1,3}address)?|account|identity|registration|sign[\\s-]?(?:in|up)|log[\\s-]?in|device)';
|
||||
const VERIFY_CONTEXT = new RegExp([
|
||||
KW,
|
||||
// "verify your email" / "confirm your account" / "authorize this transaction"
|
||||
`\\b(?:verify|confirm|activate|validate)\\s{1,3}(?:(?:your|this|the)\\s{1,3})?${VERIFY_TARGET}`,
|
||||
`\\b(?:e-?mail|account|identity|log[\\s-]?in|sign[\\s-]?in)\\s{1,3}(?:verification|confirmation|authentication)`,
|
||||
'\\bauthori[sz]e\\s{1,3}(?:(?:this|the|your)\\s{1,3})?(?:transaction|payment|login|sign[\\s-]?in|request|device)',
|
||||
'\\btwo[\\s-]?factor\\b|\\b2FA\\b',
|
||||
// zh / ja / ko
|
||||
'验证(?:您|你)?的?(?:邮箱|账号|帐号|账户|身份)|(?:邮箱|账号|帐号|身份|登录)验证|驗證(?:您|你)?的?(?:信箱|帳號|身分|身份)',
|
||||
'認証|인증',
|
||||
// ru / de / fr / es / pt / it
|
||||
'подтверд\\p{L}{0,20}\\s{1,3}(?:ваш\\p{L}{0,6}\\s{1,3})?(?:почт|e-?mail|аккаунт|учётн|учетн|вход|личност)',
|
||||
'(?:bestätigen|verifizieren)\\s{1,3}sie\\s{1,3}ihre\\s{1,3}(?:e-?mail|konto|identität)|(?:e-?mail|konto)[\\s-]?(?:adresse\\s{1,3})?(?:bestätigung|verifizierung)',
|
||||
'v[ée]rifi(?:er|ez)\\s{1,3}votre\\s{1,3}(?:adresse|e-?mail|compte|identit[ée])',
|
||||
'verific\\p{L}{0,20}\\s{1,3}(?:(?:tu|su|seu|sua|il\\s{1,3}tuo|la\\s{1,3}tua)\\s{1,3})?(?:correo|e-?mail|cuenta|conta|account|identidad|identidade|identità)',
|
||||
].join('|'), 'iu');
|
||||
|
||||
const FALLBACK_PATTERNS: RegExp[] = [
|
||||
// A code on its own line, including the first line: "Please verify your email.\n\n706215" /
|
||||
// "706215\n\nPlease verify your email". The lookbehind skips numbers under a label line such as
|
||||
// "Account ID:", which are not codes.
|
||||
new RegExp(
|
||||
`(?:^|\\n)(?:[ \\t]{0,8}\\n){0,3}[ \\t]{0,8}(?<![::][ \\t]{0,8}\\n(?:[ \\t]{0,8}\\n){0,3}[ \\t]{0,8})`
|
||||
+ `${OPEN}${DIGIT_CODE}${CLOSE}[ \\t]{0,8}(?:\\n|$)`,
|
||||
'giu'
|
||||
),
|
||||
// "Use 4821 to verify" / "Please use SGD-123456 within 3 minutes" / "请输入 123456"
|
||||
new RegExp(`(?:\\b(?:use|enter|input|type)|输入|填写|輸入|입력)\\s{0,3}${OPEN}${DIGIT_CODE}`, 'giu'),
|
||||
];
|
||||
|
||||
// Verification codes appear near the top of a mail; bounding the input keeps
|
||||
// the CPU time predictable within Workers limits for very large mails.
|
||||
const MAX_TEXT_LENGTH = 20000;
|
||||
// RFC 5322 limits a header line to 998 characters, so a real subject fits; a longer
|
||||
// one is trimmed so it can never push the body out of MAX_TEXT_LENGTH.
|
||||
const MAX_SUBJECT_LENGTH = 1000;
|
||||
|
||||
/**
|
||||
* Combine subject and body into the text passed to extractCode.
|
||||
* Many services put the code in the subject, e.g. "123456 is your verification code".
|
||||
*/
|
||||
export function joinSubjectAndBody(subject: string | undefined, body: string | undefined): string {
|
||||
return [subject?.slice(0, MAX_SUBJECT_LENGTH), body].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function extractCode(text: string): string | null {
|
||||
if (!text) return null;
|
||||
const normalized = normalizeText(text.slice(0, MAX_TEXT_LENGTH));
|
||||
// DELIM: at least one explicit delimiter must follow the code keyword before
|
||||
// the code itself. Allowed delimiters: an ASCII or full-width colon, the word
|
||||
// "is", or a common CJK delimiter particle (see the DELIM regex below).
|
||||
// Multiple delimiters in sequence are OK. Whitespace around them is allowed.
|
||||
//
|
||||
// Why mandatory: without a delimiter, "verification code Your email" would
|
||||
// capture "Your" as a false alphanumeric code. Bare "verification code 123456"
|
||||
// without any delimiter still falls through to the standalone-digit fallback.
|
||||
const DELIM = '\\s*(?:[::]|\\bis\\b|是|为|です)[\\s::]*';
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
const code = findCode(normalized, pattern);
|
||||
if (code) return code;
|
||||
// Keyword groups — labels that precede a verification code
|
||||
const CN_JA_KO_KW = '验证码|认证码|确认码|認証コード|인증\\s*코드|코드';
|
||||
const EN_KW = 'verification\\s*code|confirm(?:ation)?\\s*code|security\\s*code|passcode|OTP|pin\\s*code';
|
||||
const ALL_KW = `${CN_JA_KO_KW}|${EN_KW}`;
|
||||
|
||||
const keywordPatterns = [
|
||||
// 1. "code is 123456" / "code: 123456" — numeric preferred (bare "code" keyword)
|
||||
new RegExp(`\\bcode${DELIM}(\\d{4,12})\\b`, 'i'),
|
||||
// 2. Full keyword + mandatory delimiter + digits
|
||||
new RegExp(`(?:${ALL_KW})${DELIM}(\\d{4,12})\\b`, 'i'),
|
||||
// 3. Bare "code" + delimiter + alphanumeric
|
||||
new RegExp(`\\bcode${DELIM}([A-Za-z0-9]{4,12})\\b`, 'i'),
|
||||
// 4. Full keyword + delimiter + alphanumeric
|
||||
new RegExp(`(?:${ALL_KW})${DELIM}([A-Za-z0-9]{4,12})\\b`, 'i'),
|
||||
];
|
||||
|
||||
for (const pattern of keywordPatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1] && !looksLikeDate(match[1])) return match[1];
|
||||
}
|
||||
|
||||
if (!VERIFY_CONTEXT.test(normalized)) return null;
|
||||
|
||||
for (const pattern of FALLBACK_PATTERNS) {
|
||||
const code = findCode(normalized, pattern);
|
||||
if (code) return code;
|
||||
// Fallback: standalone digit sequence, but reject year/date patterns
|
||||
const standaloneMatch = text.match(/(?:^|\s)(\d{4,12})(?:\s|$|\.|,)/m);
|
||||
if (standaloneMatch?.[1] && !looksLikeDate(standaloneMatch[1])) {
|
||||
return standaloneMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeText(text: string): string {
|
||||
return text
|
||||
// Zero-width and soft-hyphen characters sometimes split codes, e.g. "7\u200B4\u200C9"
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF\u00AD]/g, '')
|
||||
// Full-width digits and letters: 123456 → 123456. CJK punctuation is kept,
|
||||
// so "123456,5分钟" is not read as the decimal "123456,5".
|
||||
.replace(/[\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0))
|
||||
.replace(/\r\n?/g, '\n')
|
||||
// URLs and email addresses: their digits are never verification codes
|
||||
.replace(/\bhttps?:\/\/[^\s<>"']+/gi, ' ')
|
||||
.replace(/\bwww\.[^\s<>"']+/gi, ' ')
|
||||
.replace(/[\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,8}/g, ' ');
|
||||
}
|
||||
|
||||
function findCode(text: string, pattern: RegExp): string | null {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const raw = match.slice(1).find(group => group !== undefined);
|
||||
const code = raw?.replace(/[ -]/g, '');
|
||||
if (code && isPlausibleCode(code)) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPlausibleCode(code: string): boolean {
|
||||
if (/^\d+$/.test(code)) {
|
||||
return code.length >= 4 && code.length <= 8 && !looksLikeDate(code);
|
||||
}
|
||||
// Alphanumeric codes must contain a digit, so words like "EXPIRED" or "NEVER" are not codes.
|
||||
return code.length >= 4 && code.length <= 10 && /\d/.test(code) && /^[A-Za-z0-9]+$/.test(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: does this digit sequence look like a date/year we should reject?
|
||||
* - 4 digits matching 19xx or 20xx → year (e.g. 2026)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Email extraction mode, configured by the `AI_EXTRACT_MODE` variable.
|
||||
*
|
||||
* - `local`: built-in rule-based extraction only; mail content is never sent
|
||||
* to any AI model. This is the default when the variable is unset.
|
||||
* - `ai`: prefer Workers AI; when the AI allowlist misses, local code extraction
|
||||
* still runs because it never sends mail content to AI.
|
||||
*/
|
||||
export const ExtractMode = {
|
||||
Local: 'local',
|
||||
Ai: 'ai',
|
||||
} as const;
|
||||
|
||||
export type ExtractMode = typeof ExtractMode[keyof typeof ExtractMode];
|
||||
|
||||
/**
|
||||
* Resolve the configured extraction mode.
|
||||
*
|
||||
* @returns the mode, or null when the value is not a supported mode
|
||||
*/
|
||||
export function resolveExtractMode(value: unknown): ExtractMode | null {
|
||||
if (value === undefined || value === null) return ExtractMode.Local;
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === '') return ExtractMode.Local;
|
||||
if (normalized === ExtractMode.Local) return ExtractMode.Local;
|
||||
if (normalized === ExtractMode.Ai) return ExtractMode.Ai;
|
||||
return null;
|
||||
}
|
||||
Vendored
-1
@@ -118,7 +118,6 @@ type Bindings = {
|
||||
|
||||
// AI extraction config
|
||||
ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined
|
||||
AI_EXTRACT_MODE: string | undefined
|
||||
AI_EXTRACT_MODEL: string | undefined
|
||||
|
||||
// gzip compression for raw_mails
|
||||
|
||||
@@ -150,14 +150,11 @@ ENABLE_AUTO_REPLY = false
|
||||
# REMOVE_ALL_ATTACHMENT = true
|
||||
# enable gzip compressed email storage in raw_blob column (run db_migration first)
|
||||
# ENABLE_MAIL_GZIP = true
|
||||
# Email extraction, automatically extract verification codes, auth links, etc.
|
||||
# AI email extraction, automatically extract verification codes, auth links, etc.
|
||||
# ENABLE_AI_EMAIL_EXTRACT = true
|
||||
# Extraction mode: "local" (default, built-in rules, verification codes only, mail never sent to AI)
|
||||
# or "ai" (prefer Workers AI for codes and links; allowlist misses fall back to local code extraction)
|
||||
# AI_EXTRACT_MODE = "local"
|
||||
# AI model name, choose from https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models
|
||||
# Recommended JSON Mode model: "@cf/meta/llama-3.1-8b-instruct-fast"
|
||||
# Note: "@cf/meta/llama-3.1-8b-instruct" was deprecated by Cloudflare on 2026-05-30
|
||||
# Note: "@cf/meta/llama-3.1-8b-instruct" will be deprecated by Cloudflare on 2026-05-30
|
||||
# AI_EXTRACT_MODEL = "@cf/meta/llama-3.1-8b-instruct-fast"
|
||||
# Calling other woker to process email
|
||||
# ENABLE_ANOTHER_WORKER = false
|
||||
|
||||
Reference in New Issue
Block a user