mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-17 21:34:13 +08:00
feat: add AI_EXTRACT_MODE for email extraction
This commit is contained in:
@@ -10,10 +10,14 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI 识别| 新增 `AI_EXTRACT_MODE`,可显式选择仅用本地规则(`local`)或仅用 Workers AI(`ai`)识别邮件,两者不再互相回退;不填默认使用本地规则,邮件内容不会发送给 AI。**升级注意**:原先依赖 Workers AI 绑定自动启用 AI 识别的部署需设置 `AI_EXTRACT_MODE = "ai"`
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |AI 识别| 本地验证码规则增强:同时识别邮件标题,支持验证码在关键词前(如 `116352(动态验证码)`、`ABC123 is your code`)、`G-123456` 前缀、分组 / 空格 / 零宽字符 / 全角数字,新增俄西葡法德意土希伯来语等关键词;排除超过 8 位数字、小数金额、时间、URL 与邮箱地址中的数字、tracking / order / voucher code 及纯字母单词,收紧无关键词时的数字识别,并限制分析长度、消除正则回溯风险
|
||||
|
||||
## v1.12.0
|
||||
|
||||
### Features
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI Extract| Add `AI_EXTRACT_MODE` to explicitly choose local rules only (`local`) or Workers AI only (`ai`), with no fallback between them; 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
|
||||
|
||||
### 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
|
||||
|
||||
## v1.12.0
|
||||
|
||||
### Features
|
||||
|
||||
@@ -9,5 +9,7 @@ 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 } = await request.json<{
|
||||
from: string; to: string; raw: string; ai_extract_result?: unknown;
|
||||
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;
|
||||
}>();
|
||||
if (!from || !to || !raw) {
|
||||
return new Response("from, to and raw are required", { status: 400 });
|
||||
@@ -60,11 +60,12 @@ 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
|
||||
const emailEnv = ai_extract_result || extract_mode !== undefined
|
||||
? { ...env, ...aiExtractEnvOverrides }
|
||||
: env;
|
||||
await emailHandler(mockMessage, emailEnv, ctx);
|
||||
|
||||
@@ -52,4 +52,54 @@ 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { extractCode, joinSubjectAndBody } from '../../../worker/src/email/extract_code.ts';
|
||||
import { 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), 'local');
|
||||
assert.equal(resolveExtractMode(''), 'local');
|
||||
assert.equal(resolveExtractMode(' '), 'local');
|
||||
});
|
||||
|
||||
test('extract mode accepts explicit ai and local', () => {
|
||||
assert.equal(resolveExtractMode('ai'), 'ai');
|
||||
assert.equal(resolveExtractMode(' AI '), 'ai');
|
||||
assert.equal(resolveExtractMode('local'), 'local');
|
||||
});
|
||||
|
||||
test('extract mode rejects unknown values', () => {
|
||||
assert.equal(resolveExtractMode('auto'), null);
|
||||
assert.equal(resolveExtractMode('regex'), null);
|
||||
assert.equal(resolveExtractMode(true), null);
|
||||
});
|
||||
@@ -7,7 +7,14 @@
|
||||
|
||||
## Features
|
||||
|
||||
The AI email recognition feature uses Cloudflare Workers AI to automatically analyze incoming email content and intelligently extract important information, including:
|
||||
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:
|
||||
|
||||
- **Verification Code** (auth_code) - OTP, security code, confirmation code, etc.
|
||||
- **Authentication Link** (auth_link) - Login, verify, activate, password reset links
|
||||
@@ -15,26 +22,54 @@ The AI email recognition feature uses Cloudflare Workers AI to automatically ana
|
||||
- **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, and the frontend can directly display extracted verification codes or 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.
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
| Variable Name | Type | Description | Example |
|
||||
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `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` |
|
||||
| `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` uses Workers AI only. 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` |
|
||||
|
||||
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.
|
||||
> [!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"`.
|
||||
|
||||
## Content Length Limit
|
||||
The two modes **never fall back to each other**:
|
||||
|
||||
- `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
|
||||
|
||||
## 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
|
||||
|
||||
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"
|
||||
```
|
||||
@@ -43,21 +78,9 @@ 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:
|
||||
To control costs and resource usage, you can configure an address allowlist in the Admin console's **AI Extract Settings** page (applies to both `local` and `ai` modes):
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
|
||||
## 功能说明
|
||||
|
||||
AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内容,智能提取重要信息,包括:
|
||||
邮件识别功能会自动分析收到的邮件内容,提取其中的重要信息,并支持两种互斥的识别模式:
|
||||
|
||||
| 模式 | 识别内容 | 隐私 | 依赖 |
|
||||
| ---- | -------- | ---- | ---- |
|
||||
| `local`(默认) | 仅**验证码** (auth_code) | 在 Worker 内用内置规则识别,邮件内容**不会发送给任何 AI 模型** | 无,零成本 |
|
||||
| `ai` | 验证码、认证链接、服务链接、订阅管理链接、其他链接 | 邮件内容会发送给你 Cloudflare 账号下的 Workers AI 模型 | Workers AI 绑定 |
|
||||
|
||||
`ai` 模式可识别的类型:
|
||||
|
||||
- **验证码** (auth_code) - OTP、安全码、确认码等
|
||||
- **认证链接** (auth_link) - 登录、验证、激活、重置密码链接
|
||||
@@ -15,26 +22,54 @@ AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内
|
||||
- **订阅管理链接** (subscription_link) - 退订、管理订阅等链接
|
||||
- **其他链接** (other_link) - 其他有价值的链接
|
||||
|
||||
提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接。
|
||||
提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接,Telegram 推送与 Webhook 占位符也会复用该结果。
|
||||
|
||||
## 配置变量
|
||||
|
||||
| 变量名 | 类型 | 说明 | 示例 |
|
||||
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
推荐使用 `@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 弃用,不建议继续使用。
|
||||
> [!WARNING] 从旧版本升级
|
||||
> 旧版本在配置了 Workers AI 绑定时会自动使用 AI 识别。现在不填 `AI_EXTRACT_MODE` 时默认使用本地规则,如需继续使用 AI 识别,请显式设置 `AI_EXTRACT_MODE = "ai"`。
|
||||
|
||||
## 内容长度限制
|
||||
两种模式之间**不会互相回退**:
|
||||
|
||||
- `local` 模式即使配置了 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"
|
||||
```
|
||||
@@ -43,21 +78,9 @@ 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 提取设置** 页面配置地址白名单:
|
||||
为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单(对 `local` 与 `ai` 两种模式都生效):
|
||||
|
||||
### 配置说明
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* AI Email Extraction Module
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { commonParseMail } from "../common";
|
||||
import { extractCode } from "./extract_code";
|
||||
import { extractCode, joinSubjectAndBody } from "./extract_code";
|
||||
import { resolveExtractMode } from "./extract_mode";
|
||||
import { getBooleanValue, getJsonSetting } from "../utils";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { Context } from "hono";
|
||||
@@ -141,7 +142,7 @@ async function extractWithCloudflareAI(
|
||||
|
||||
/**
|
||||
* Persist an extraction result to the raw_mails metadata column.
|
||||
* Shared by the Workers AI path and the regex fallback path.
|
||||
* Shared by the Workers AI mode and the local rule mode.
|
||||
*
|
||||
* @param env - Cloudflare Workers environment bindings
|
||||
* @param message_id - The email message ID
|
||||
@@ -229,8 +230,9 @@ function getEmailContentForExtract(parsedEmail: Awaited<ReturnType<typeof common
|
||||
/**
|
||||
* Main extraction function
|
||||
* Checks if extraction is enabled, processes the email content, and saves to database.
|
||||
* Uses Cloudflare Workers AI when the `AI` binding is available; otherwise falls back
|
||||
* to a built-in regex extractor that surfaces verification codes only.
|
||||
* `AI_EXTRACT_MODE` selects exactly one extractor, with no fallback between them:
|
||||
* - `local` (default): built-in rules, verification codes only, content never sent to AI
|
||||
* - `ai`: Cloudflare Workers AI, verification codes and links
|
||||
*
|
||||
* @param parsedEmailContext - The parsed email context
|
||||
* @param env - Cloudflare Workers environment bindings
|
||||
@@ -250,7 +252,17 @@ export async function extractEmailInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check allowlist if enabled (applies to both AI and the regex fallback)
|
||||
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;
|
||||
}
|
||||
if (mode === 'ai' && !env.AI) {
|
||||
console.error('Email extraction skipped: AI_EXTRACT_MODE is "ai" but the Workers AI binding "AI" is not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check allowlist if enabled (applies to both modes)
|
||||
const aiSettings = await getJsonSetting<AiExtractSettings>(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
CONSTANTS.AI_EXTRACT_SETTINGS_KEY
|
||||
@@ -277,28 +289,29 @@ export async function extractEmailInfo(
|
||||
}
|
||||
}
|
||||
|
||||
// Parse email to get content (shared by the AI path and the regex fallback)
|
||||
// Parse email to get content (shared by both modes)
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const emailContent = getEmailContentForExtract(parsedEmail);
|
||||
|
||||
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);
|
||||
// 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 === 'local') {
|
||||
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(`Regex code extraction completed for ${message_id}`);
|
||||
console.log(`Local code extraction completed for ${message_id}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!emailContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Truncate content if too long (max 4000 characters to avoid token limits)
|
||||
const truncatedContent = emailContent.length > 4000
|
||||
? emailContent.substring(0, 4000) + '...[truncated]'
|
||||
|
||||
@@ -1,61 +1,205 @@
|
||||
/**
|
||||
* Regex-based verification code extraction.
|
||||
* Local rule-based verification code extraction.
|
||||
*
|
||||
* Extracts verification codes (4-12 digit / alphanumeric) from email text.
|
||||
* Covers common formats across English, Chinese, Japanese and Korean.
|
||||
* Extracts verification codes from email text without calling any AI model,
|
||||
* so mail content never leaves the regex engine inside the Worker.
|
||||
*
|
||||
* This is a zero-dependency fallback used when Workers AI is not bound, so
|
||||
* self-hosted deployments without Workers AI still surface verification codes.
|
||||
* 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 / 输入".
|
||||
*
|
||||
* 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.
|
||||
* 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).
|
||||
*/
|
||||
export function extractCode(text: string): string | null {
|
||||
// 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::]*';
|
||||
|
||||
// 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 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('|')})`;
|
||||
|
||||
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'),
|
||||
// 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'),
|
||||
];
|
||||
|
||||
for (const pattern of keywordPatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1] && !looksLikeDate(match[1])) return match[1];
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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];
|
||||
export function extractCode(text: string): string | null {
|
||||
if (!text) return null;
|
||||
const normalized = normalizeText(text.slice(0, MAX_TEXT_LENGTH));
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
const code = findCode(normalized, pattern);
|
||||
if (code) return code;
|
||||
}
|
||||
|
||||
if (!VERIFY_CONTEXT.test(normalized)) return null;
|
||||
|
||||
for (const pattern of FALLBACK_PATTERNS) {
|
||||
const code = findCode(normalized, pattern);
|
||||
if (code) return code;
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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`: Workers AI only; no local fallback when the binding is missing or
|
||||
* the model call fails.
|
||||
*/
|
||||
export type ExtractMode = 'local' | 'ai';
|
||||
|
||||
/**
|
||||
* 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 'local';
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === '') return 'local';
|
||||
if (normalized === 'local' || normalized === 'ai') return normalized;
|
||||
return null;
|
||||
}
|
||||
Vendored
+1
@@ -118,6 +118,7 @@ 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,11 +150,14 @@ 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
|
||||
# AI email extraction, automatically extract verification codes, auth links, etc.
|
||||
# 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" (Workers AI only, codes and links, requires the [ai] binding, no local fallback)
|
||||
# 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" will be deprecated by Cloudflare on 2026-05-30
|
||||
# Note: "@cf/meta/llama-3.1-8b-instruct" was 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