fix: fallback to local extract on ai allowlist miss

This commit is contained in:
dreamhunter2333
2026-09-17 13:06:52 +08:00
parent 1e054578d8
commit 67c6c1f82c
9 changed files with 233 additions and 66 deletions
+3 -1
View File
@@ -10,10 +10,12 @@
### Features ### Features
- feat: |AI 识别| 新增 `AI_EXTRACT_MODE`,可显式选择仅用本地规则(`local`)或用 Workers AI`ai`)识别邮件,两者不再互相回退;不填默认使用本地规则,邮件内容不会发送给 AI。**升级注意**:原先依赖 Workers AI 绑定自动启用 AI 识别的部署需设置 `AI_EXTRACT_MODE = "ai"` - feat: |AI 识别| 新增 `AI_EXTRACT_MODE`,可显式选择仅用本地规则(`local`)或优先用 Workers AI`ai`)识别邮件;不填默认使用本地规则,邮件内容不会发送给 AI。**升级注意**:原先依赖 Workers AI 绑定自动启用 AI 识别的部署需设置 `AI_EXTRACT_MODE = "ai"`
### Bug Fixes ### Bug Fixes
- fix: |AI 识别| `ai` 模式下地址未命中 AI 提取白名单时只跳过 Workers AI 调用,仍回退到本地规则提取验证码
### Improvements ### Improvements
- feat: |AI 识别| 本地验证码规则增强:同时识别邮件标题,支持验证码在关键词前(如 `116352(动态验证码)``ABC123 is your code`)、`G-123456` 前缀、分组 / 空格 / 零宽字符 / 全角数字,新增俄西葡法德意土希伯来语等关键词;排除超过 8 位数字、小数金额、时间、URL 与邮箱地址中的数字、tracking / order / voucher code 及纯字母单词,收紧无关键词时的数字识别,并限制分析长度、消除正则回溯风险 - feat: |AI 识别| 本地验证码规则增强:同时识别邮件标题,支持验证码在关键词前(如 `116352(动态验证码)``ABC123 is your code`)、`G-123456` 前缀、分组 / 空格 / 零宽字符 / 全角数字,新增俄西葡法德意土希伯来语等关键词;排除超过 8 位数字、小数金额、时间、URL 与邮箱地址中的数字、tracking / order / voucher code 及纯字母单词,收紧无关键词时的数字识别,并限制分析长度、消除正则回溯风险
+3 -1
View File
@@ -10,10 +10,12 @@
### Features ### 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"` - 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 ### 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 ### 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 - 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
+158 -1
View File
@@ -1,5 +1,7 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers'; import { WORKER_URL, WORKER_URL_ENV_OFF, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test.describe('Telegram AI extraction rendering', () => { test.describe('Telegram AI extraction rendering', () => {
test('realtime mail stores AI extraction metadata for Telegram rendering', async ({ request }) => { test('realtime mail stores AI extraction metadata for Telegram rendering', async ({ request }) => {
@@ -102,4 +104,159 @@ test.describe('Telegram AI extraction rendering', () => {
await deleteAddress(request, jwt); 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);
}
});
}); });
+7 -7
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { extractCode, joinSubjectAndBody } from '../../../worker/src/email/extract_code.ts'; import { extractCode, joinSubjectAndBody } from '../../../worker/src/email/extract_code.ts';
import { resolveExtractMode } from '../../../worker/src/email/extract_mode.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). // Messages marked "2FHey" are adapted from https://github.com/SoFriendly/2fhey tests (CC0-1.0).
const codeCases = [ const codeCases = [
@@ -161,15 +161,15 @@ for (const [name, text] of Object.entries(hostileInputs)) {
} }
test('extract mode defaults to local when unset', () => { test('extract mode defaults to local when unset', () => {
assert.equal(resolveExtractMode(undefined), 'local'); assert.equal(resolveExtractMode(undefined), ExtractMode.Local);
assert.equal(resolveExtractMode(''), 'local'); assert.equal(resolveExtractMode(''), ExtractMode.Local);
assert.equal(resolveExtractMode(' '), 'local'); assert.equal(resolveExtractMode(' '), ExtractMode.Local);
}); });
test('extract mode accepts explicit ai and local', () => { test('extract mode accepts explicit ai and local', () => {
assert.equal(resolveExtractMode('ai'), 'ai'); assert.equal(resolveExtractMode('ai'), ExtractMode.Ai);
assert.equal(resolveExtractMode(' AI '), 'ai'); assert.equal(resolveExtractMode(' AI '), ExtractMode.Ai);
assert.equal(resolveExtractMode('local'), 'local'); assert.equal(resolveExtractMode('local'), ExtractMode.Local);
}); });
test('extract mode rejects unknown values', () => { test('extract mode rejects unknown values', () => {
@@ -29,16 +29,17 @@ Extraction results are automatically saved to the `metadata` field in the databa
| Variable Name | Type | Description | Example | | Variable Name | Type | Description | Example |
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable email recognition (master switch, required by both modes) | `true` | | `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_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` | | `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` |
> [!WARNING] Upgrading from older versions > [!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"`. > 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"`.
The two modes **never fall back to each other**: The two modes behave as follows:
- `local` mode never calls AI, even if a Workers AI binding is configured - `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 - `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) ## Local Rule Mode (local)
@@ -80,12 +81,12 @@ Or add in Cloudflare Dashboard Worker settings:
## Address Allowlist (Optional) ## Address Allowlist (Optional)
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): 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.
### Configuration ### Configuration
- **Allowlist Disabled**: AI extraction will process all email addresses - **Allowlist Disabled**: Workers AI extraction can process all email addresses
- **Allowlist Enabled**: AI extraction will only process addresses in the allowlist - **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 Format ### Allowlist Format
@@ -105,7 +106,7 @@ user@example.com
admin*@company.com admin*@company.com
``` ```
This configuration will only perform AI extraction for: This configuration will only call Workers AI for:
- `user@example.com` (exact match) - `user@example.com` (exact match)
- All emails under `@mydomain.com` (e.g., `test@mydomain.com`, `admin@mydomain.com`) - 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`) - All emails starting with `admin` under `@company.com` (e.g., `admin@company.com`, `admin123@company.com`)
@@ -29,16 +29,17 @@
| 变量名 | 类型 | 说明 | 示例 | | 变量名 | 类型 | 说明 | 示例 |
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用邮件识别功能(总开关,两种模式都需要) | `true` | | `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用邮件识别功能(总开关,两种模式都需要) | `true` |
| `AI_EXTRACT_MODE` | 文本 | 识别模式:`local` 仅用内置规则,`ai` 用 Workers AI。不填默认为 `local`,填写其他值会记录错误日志并跳过识别 | `local` | | `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` | | `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` |
> [!WARNING] 从旧版本升级 > [!WARNING] 从旧版本升级
> 旧版本在配置了 Workers AI 绑定时会自动使用 AI 识别。现在不填 `AI_EXTRACT_MODE` 时默认使用本地规则,如需继续使用 AI 识别,请显式设置 `AI_EXTRACT_MODE = "ai"`。 > 旧版本在配置了 Workers AI 绑定时会自动使用 AI 识别。现在不填 `AI_EXTRACT_MODE` 时默认使用本地规则,如需继续使用 AI 识别,请显式设置 `AI_EXTRACT_MODE = "ai"`。
两种模式之间**不会互相回退** 两种模式的主要行为
- `local` 模式即使配置了 Workers AI 绑定,也不会调用 AI - `local` 模式即使配置了 Workers AI 绑定,也不会调用 AI
- `ai` 模式未配置 Workers AI 绑定或调用模型失败时,会记录错误日志并跳过本封邮件的识别,不会改用本地规则 - `ai` 模式未配置 Workers AI 绑定或调用模型失败时,会记录错误日志并跳过本封邮件的识别,不会改用本地规则
- `ai` 模式下如果地址未命中 AI 提取白名单,只会跳过 Workers AI 调用,仍会改用本地规则尝试提取验证码
## 本地规则模式(local ## 本地规则模式(local
@@ -80,12 +81,12 @@ binding = "AI"
## 地址白名单(可选) ## 地址白名单(可选)
为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单(对 `local``ai` 两种模式都生效): 为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单。白名单只控制 Workers AI 调用,不限制本地规则模式;`ai` 模式下未命中白名单的地址仍会使用本地规则尝试提取验证码。
### 配置说明 ### 配置说明
- **未启用白名单**:所有邮箱地址都可使用 AI 提取功能 - **未启用白名单**:所有邮箱地址都可使用 Workers AI 提取
- **启用白名单**:仅白名单中的邮箱地址会进行 AI 提取 - **启用白名单**:仅白名单中的邮箱地址会调用 Workers AI;未命中的地址会跳过 Workers AI,并回退到本地验证码提取
### 白名单格式 ### 白名单格式
@@ -105,7 +106,7 @@ user@example.com
admin*@company.com admin*@company.com
``` ```
此配置将只对以下邮箱进行 AI 提取 此配置将只对以下邮箱调用 Workers AI
- `user@example.com`(精确匹配) - `user@example.com`(精确匹配)
- 所有 `@mydomain.com` 的邮箱(如 `test@mydomain.com``admin@mydomain.com` - 所有 `@mydomain.com` 的邮箱(如 `test@mydomain.com``admin@mydomain.com`
- 所有 `admin` 开头的 `@company.com` 邮箱(如 `admin@company.com``admin123@company.com` - 所有 `admin` 开头的 `@company.com` 邮箱(如 `admin@company.com``admin123@company.com`
+35 -37
View File
@@ -8,7 +8,7 @@
import { commonParseMail } from "../common"; import { commonParseMail } from "../common";
import { extractCode, joinSubjectAndBody } from "./extract_code"; import { extractCode, joinSubjectAndBody } from "./extract_code";
import { resolveExtractMode } from "./extract_mode"; import { ExtractMode, resolveExtractMode } from "./extract_mode";
import { getBooleanValue, getJsonSetting } from "../utils"; import { getBooleanValue, getJsonSetting } from "../utils";
import { CONSTANTS } from "../constants"; import { CONSTANTS } from "../constants";
import { Context } from "hono"; import { Context } from "hono";
@@ -227,12 +227,24 @@ function getEmailContentForExtract(parsedEmail: Awaited<ReturnType<typeof common
return htmlToTextForAi(parsedEmail.html) || parsedEmail.html; 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 * Main extraction function
* Checks if extraction is enabled, processes the email content, and saves to database. * Checks if extraction is enabled, processes the email content, and saves to database.
* `AI_EXTRACT_MODE` selects exactly one extractor, with no fallback between them: * `AI_EXTRACT_MODE` selects the preferred extractor:
* - `local` (default): built-in rules, verification codes only, content never sent to AI * - `local` (default): built-in rules, verification codes only, content never sent to AI
* - `ai`: Cloudflare Workers AI, verification codes and links * - `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
* *
* @param parsedEmailContext - The parsed email context * @param parsedEmailContext - The parsed email context
* @param env - Cloudflare Workers environment bindings * @param env - Cloudflare Workers environment bindings
@@ -257,55 +269,41 @@ export async function extractEmailInfo(
console.error(`Email extraction skipped: unsupported AI_EXTRACT_MODE "${env.AI_EXTRACT_MODE}", expected "local" or "ai"`); console.error(`Email extraction skipped: unsupported AI_EXTRACT_MODE "${env.AI_EXTRACT_MODE}", expected "local" or "ai"`);
return null; 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>( const aiSettings = await getJsonSetting<AiExtractSettings>(
{ env: env } as Context<HonoCustomType>, { env: env } as Context<HonoCustomType>,
CONSTANTS.AI_EXTRACT_SETTINGS_KEY CONSTANTS.AI_EXTRACT_SETTINGS_KEY
); );
const isAiAllowed = isAddressInAiAllowlist(aiSettings, address);
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 both modes) // Parse email to get content (shared by both modes)
const parsedEmail = await commonParseMail(parsedEmailContext); const parsedEmail = await commonParseMail(parsedEmailContext);
const emailContent = getEmailContentForExtract(parsedEmail); const emailContent = getEmailContentForExtract(parsedEmail);
// Local mode: built-in rules only, mail content is never sent to any AI model. const runLocalExtract = async () => {
// 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 localContent = joinSubjectAndBody(parsedEmail?.subject, emailContent);
const code = localContent ? extractCode(localContent) : null; const code = localContent ? extractCode(localContent) : null;
if (!code) { if (!code) return null;
return null;
}
const result: ExtractResult = { type: 'auth_code', result: code, result_text: '' }; const result: ExtractResult = { type: 'auth_code', result: code, result_text: '' };
await saveExtractMetadata(env, message_id, result); await saveExtractMetadata(env, message_id, result);
console.log(`Local code extraction completed for ${message_id}`); console.log(`Local code extraction completed for ${message_id}`);
return result; 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) { if (!emailContent) {
+12 -6
View File
@@ -3,10 +3,15 @@
* *
* - `local`: built-in rule-based extraction only; mail content is never sent * - `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. * 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 * - `ai`: prefer Workers AI; when the AI allowlist misses, local code extraction
* the model call fails. * still runs because it never sends mail content to AI.
*/ */
export type ExtractMode = 'local' | 'ai'; export const ExtractMode = {
Local: 'local',
Ai: 'ai',
} as const;
export type ExtractMode = typeof ExtractMode[keyof typeof ExtractMode];
/** /**
* Resolve the configured extraction mode. * Resolve the configured extraction mode.
@@ -14,10 +19,11 @@ export type ExtractMode = 'local' | 'ai';
* @returns the mode, or null when the value is not a supported mode * @returns the mode, or null when the value is not a supported mode
*/ */
export function resolveExtractMode(value: unknown): ExtractMode | null { export function resolveExtractMode(value: unknown): ExtractMode | null {
if (value === undefined || value === null) return 'local'; if (value === undefined || value === null) return ExtractMode.Local;
if (typeof value !== 'string') return null; if (typeof value !== 'string') return null;
const normalized = value.trim().toLowerCase(); const normalized = value.trim().toLowerCase();
if (normalized === '') return 'local'; if (normalized === '') return ExtractMode.Local;
if (normalized === 'local' || normalized === 'ai') return normalized; if (normalized === ExtractMode.Local) return ExtractMode.Local;
if (normalized === ExtractMode.Ai) return ExtractMode.Ai;
return null; return null;
} }
+1 -1
View File
@@ -153,7 +153,7 @@ ENABLE_AUTO_REPLY = false
# Email extraction, automatically extract verification codes, auth links, etc. # Email extraction, automatically extract verification codes, auth links, etc.
# ENABLE_AI_EMAIL_EXTRACT = true # ENABLE_AI_EMAIL_EXTRACT = true
# Extraction mode: "local" (default, built-in rules, verification codes only, mail never sent to AI) # 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) # or "ai" (prefer Workers AI for codes and links; allowlist misses fall back to local code extraction)
# AI_EXTRACT_MODE = "local" # AI_EXTRACT_MODE = "local"
# AI model name, choose from https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models # 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" # Recommended JSON Mode model: "@cf/meta/llama-3.1-8b-instruct-fast"