Compare commits

..
Author SHA1 Message Date
dreamhunter2333 67c6c1f82c fix: fallback to local extract on ai allowlist miss 2026-09-17 13:06:52 +08:00
jiaxin 1e054578d8 feat: add AI_EXTRACT_MODE for email extraction 2026-09-17 12:40:41 +08:00
Dream Hunter cea2a8c70d docs: clarify database migration notes in release skill (#1150)
* docs: clarify database migration notes in release skill

* docs: use concise per-feature release upgrade guidance

* docs: simplify feature upgrade notes wording

* docs: omit product changelog for skill-only changes
2026-09-14 00:39:34 +08:00
Dream Hunter 25ca019f6a chore: upgrade dependencies (#1149) 2026-09-13 19:39:15 +08:00
Dream Hunter 952ab6f5d6 feat: upgrade version to v1.13.0 (#1148)
- Update version number to 1.13.0 in all package.json files
- Add v1.13.0 placeholder in CHANGELOG.md and CHANGELOG_EN.md
- Move (main) marker from v1.12.0 to v1.13.0
2026-09-13 18:57:29 +08:00
25 changed files with 1903 additions and 1201 deletions
+7 -3
View File
@@ -16,13 +16,17 @@ description: Create a GitHub release for cloudflare_temp_email project. Use when
gh pr list --state merged --search "is:pr is:merged merged:>$SINCE base:main" --json number,title,author --limit 200 gh pr list --state merged --search "is:pr is:merged merged:>$SINCE base:main" --json number,title,author --limit 200
``` ```
Sort by PR number ascending. Sort by PR number ascending.
4. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules: 4. **Check database upgrades**: Compare the previous release tag with the exact release target, including `db/` migrations, `db/schema.sql`, and the Admin database-update implementation. Use `git diff <previous-tag> <target> -- db/` (fetch missing refs first), not PR timestamps alone. Read each changed migration to determine required upgrade steps and effects on existing data. Only recommend the Admin database-update button after verifying it applies these migrations. Do not execute database changes as part of release preparation.
5. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules:
- Write release body in **bilingual format**: Chinese sections first (from `CHANGELOG.md`), then wrap the English sections (from `CHANGELOG_EN.md`) in `<details><summary>English</summary>...</details>`. - Write release body in **bilingual format**: Chinese sections first (from `CHANGELOG.md`), then wrap the English sections (from `CHANGELOG_EN.md`) in `<details><summary>English</summary>...</details>`.
- Copy changelog sections verbatim from both files. Omit empty sections. - Copy changelog sections verbatim from both files. Omit empty sections.
- For features requiring database migrations, follow release `v1.5.0`: add exactly three concise, natural-language sub-bullets under each corresponding feature in both languages. In order, explain how to enable it; the release-tag-pinned SQL migration link or verified Admin page action, plus whether unused features can skip it; and what the feature does. Do not prefix these lines with labels such as "Feature switch:", "Database changes:" or "Functionality:". Keep each feature's three lines separate, without a shared Database Changes section or classifying optional migrations as Breaking Changes. Preserve the original changelog bullet; these sub-bullets supplement it.
- State which feature requires each migration. Verify the feature's disabled code paths before saying it is optional: if the feature is not used and existing functionality does not depend on the migration, explicitly say users can skip it unless they enable that feature later. Distinguish these optional migrations from migrations required for normal operation; do not describe every migration as mandatory for all deployments.
- Keep migration instructions accurate and short: never recommend reapplying an already-applied non-idempotent migration. Include any destructive data impact in the database sub-bullet rather than hiding it to meet the format. Resolve unclear upgrade paths before publishing; do not invent switches or migration actions.
- Wrap PRs list in `<details><summary>PRs</summary>...</details>`. - Wrap PRs list in `<details><summary>PRs</summary>...</details>`.
- Always include the cache-clearing discussion link. - Always include the cache-clearing discussion link.
- End with `**Full Changelog**` comparison link. - End with `**Full Changelog**` comparison link.
5. **Create release**: 6. **Create release**:
- Write body to a temp file (e.g. `/tmp/release-notes.md`) - Write body to a temp file (e.g. `/tmp/release-notes.md`)
- Run: `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes.md --target main` - Run: `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes.md --target main`
6. **Verify**: Confirm the release URL and ask the user to review. 7. **Verify**: Read back the published body, verify all required migration links and bilingual upgrade instructions are present, then confirm the release URL and ask the user to review.
@@ -8,6 +8,9 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
### Features ### Features
- feat: |模块| 描述 - feat: |模块| 描述
- 设置对应环境变量开启。
- 启用前执行对应迁移 SQL,或在已验证的 Admin 数据库页面升级;不使用此可选功能可跳过。
- 简述启用后的行为。
### Bug Fixes ### Bug Fixes
@@ -21,6 +24,20 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
- style/refactor/perf/docs: |模块| 描述 - style/refactor/perf/docs: |模块| 描述
<details>
<summary>English</summary>
### Features
- feat: |Module| Description
- Set the corresponding environment variable to enable it.
- Before enabling it, apply the migration SQL or use the verified Admin database-upgrade action; skip it if the optional feature is unused.
- Briefly describe the enabled behavior.
<!-- Copy the remaining matching English changelog sections here. -->
</details>
### [更新或者部署网页不生效请如图勾选清理缓存](https://github.com/dreamhunter2333/cloudflare_temp_email/discussions/487) ### [更新或者部署网页不生效请如图勾选清理缓存](https://github.com/dreamhunter2333/cloudflare_temp_email/discussions/487)
<details> <details>
@@ -36,6 +53,8 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
## Notes ## Notes
- Sections without entries should be omitted - Sections without entries should be omitted
- For features requiring migrations, use three concise sub-bullets under each feature: switch, database changes, functionality, without heading-like labels on the lines. Replace guidance with verified variables, release-tag-pinned SQL links and supported UI actions. Do not add a shared Database Changes section. Features without migrations do not need this format.
- Verify whether each migration is optional or mandatory from actual feature dependencies. Preserve any separate Breaking Changes entries and provide matching Chinese and English upgrade notes.
- PRs section uses `<details>` to collapse by default - PRs section uses `<details>` to collapse by default
- PRs are sorted by PR number ascending - PRs are sorted by PR number ascending
- The cache clearing discussion link is always included - The cache clearing discussion link is always included
+15 -1
View File
@@ -6,7 +6,21 @@
<a href="CHANGELOG_EN.md">English</a> <a href="CHANGELOG_EN.md">English</a>
</p> </p>
## v1.12.0(main) ## v1.13.0(main)
### 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 及纯字母单词,收紧无关键词时的数字识别,并限制分析长度、消除正则回溯风险
## v1.12.0
### Features ### Features
+15 -1
View File
@@ -6,7 +6,21 @@
<a href="CHANGELOG_EN.md">English</a> <a href="CHANGELOG_EN.md">English</a>
</p> </p>
## v1.12.0(main) ## v1.13.0(main)
### 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
## v1.12.0
### Features ### Features
+2
View File
@@ -9,5 +9,7 @@ COPY e2e/package.json e2e/package-lock.json ./
RUN npm ci RUN npm ci
COPY e2e/ . 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"] ENTRYPOINT ["/app/e2e/scripts/docker-entrypoint.sh"]
+4 -3
View File
@@ -31,8 +31,8 @@ const seedMail = async (request: Request, env: Bindings) => {
// Exercises the real email() handler with a mock ForwardableEmailMessage. // Exercises the real email() handler with a mock ForwardableEmailMessage.
const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => { const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => {
const { from, to, raw, ai_extract_result } = await request.json<{ const { from, to, raw, ai_extract_result, extract_mode } = await request.json<{
from: string; to: string; raw: string; ai_extract_result?: unknown; from: string; to: string; raw: string; ai_extract_result?: unknown; extract_mode?: string;
}>(); }>();
if (!from || !to || !raw) { if (!from || !to || !raw) {
return new Response("from, to and raw are required", { status: 400 }); 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 { email: emailHandler } = await import('../../worker/src/email');
const aiExtractEnvOverrides: Partial<Bindings> = { const aiExtractEnvOverrides: Partial<Bindings> = {
ENABLE_AI_EMAIL_EXTRACT: true, ENABLE_AI_EMAIL_EXTRACT: true,
AI_EXTRACT_MODE: extract_mode ?? 'ai',
AI: { AI: {
run: async () => ({ response: ai_extract_result }) run: async () => ({ response: ai_extract_result })
} as unknown as Ai, } as unknown as Ai,
}; };
const emailEnv = ai_extract_result const emailEnv = ai_extract_result || extract_mode !== undefined
? { ...env, ...aiExtractEnvOverrides } ? { ...env, ...aiExtractEnvOverrides }
: env; : env;
await emailHandler(mockMessage, emailEnv, ctx); await emailHandler(mockMessage, emailEnv, ctx);
+18 -15
View File
@@ -6,8 +6,8 @@
"": { "": {
"name": "cloudflare-temp-email-e2e", "name": "cloudflare-temp-email-e2e",
"dependencies": { "dependencies": {
"imapflow": "^1.7.8", "imapflow": "^2.0.2",
"nodemailer": "^10.0.0" "nodemailer": "^10.0.9"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
@@ -114,9 +114,9 @@
} }
}, },
"node_modules/imapflow": { "node_modules/imapflow": {
"version": "1.7.8", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz", "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.0.2.tgz",
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==", "integrity": "sha512-jrWWecWFifN3Feum2MqaTUupNeYSAdPEltKuwGdr8IPdxW72NZXf7ObVUfTWoUH2MPrli0q7SZm9pRt8ob/9XQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@zone-eu/mailsplit": "5.4.16", "@zone-eu/mailsplit": "5.4.16",
@@ -126,13 +126,16 @@
"libmime": "5.4.3", "libmime": "5.4.3",
"libqp": "2.1.1", "libqp": "2.1.1",
"pino": "10.3.1", "pino": "10.3.1",
"socks": "2.8.9" "socks": "2.8.10"
},
"engines": {
"node": ">=20.0.0"
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.3.1", "version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -163,9 +166,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "10.0.0", "version": "10.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.9.tgz",
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==", "integrity": "sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -303,9 +306,9 @@
} }
}, },
"node_modules/socks": { "node_modules/socks": {
"version": "2.8.9", "version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ip-address": "^10.1.1", "ip-address": "^10.1.1",
+2 -2
View File
@@ -14,7 +14,7 @@
"ws": "^8.21.3" "ws": "^8.21.3"
}, },
"dependencies": { "dependencies": {
"imapflow": "^1.7.8", "imapflow": "^2.0.2",
"nodemailer": "^10.0.0" "nodemailer": "^10.0.9"
} }
} }
+208 -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 }) => {
@@ -52,4 +54,209 @@ test.describe('Telegram AI extraction rendering', () => {
await deleteAddress(request, jwt); 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);
}
});
}); });
+179
View File
@@ -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 { 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
['您的验证码是 1234565分钟内有效', '123456'],
['【某某】验证码:839201,请勿泄露', '839201'],
['验证码12345610分钟内有效', '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);
});
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.12.0", "version": "1.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -30,7 +30,7 @@
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.20.0", "axios": "^1.20.0",
"dompurify": "^3.4.15", "dompurify": "^3.4.15",
"jszip": "^3.10.1", "jszip": "^3.10.2",
"mail-parser-wasm": "^0.2.2", "mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.45.3", "naive-ui": "^2.45.3",
"postal-mime": "^2.7.6", "postal-mime": "^2.7.6",
@@ -53,7 +53,7 @@
"vitest": "^4.1.11", "vitest": "^4.1.11",
"workbox-build": "^7.4.1", "workbox-build": "^7.4.1",
"workbox-window": "^7.4.1", "workbox-window": "^7.4.1",
"wrangler": "^4.129.0" "wrangler": "^4.131.1"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+377 -370
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "temp-email-pages", "name": "temp-email-pages",
"version": "1.12.0", "version": "1.13.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -11,7 +11,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"wrangler": "^4.129.0" "wrangler": "^4.131.1"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
@@ -7,7 +7,14 @@
## Features ## 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. - **Verification Code** (auth_code) - OTP, security code, confirmation code, etc.
- **Authentication Link** (auth_link) - Login, verify, activate, password reset links - **Authentication Link** (auth_link) - Login, verify, activate, password reset links
@@ -15,26 +22,55 @@ The AI email recognition feature uses Cloudflare Workers AI to automatically ana
- **Subscription Link** (subscription_link) - Unsubscribe, manage subscription links - **Subscription Link** (subscription_link) - Unsubscribe, manage subscription links
- **Other Link** (other_link) - Other valuable 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 ## Configuration Variables
| Variable Name | Type | Description | Example | | Variable Name | Type | Description | Example |
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable AI email recognition feature | `true` | | `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable email recognition (master switch, required by both modes) | `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` | | `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` |
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 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
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. 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`: Configure Workers AI binding in `wrangler.toml`:
```toml ```toml
AI_EXTRACT_MODE = "ai"
[ai] [ai]
binding = "AI" binding = "AI"
``` ```
@@ -43,26 +79,14 @@ Or add in Cloudflare Dashboard Worker settings:
- **Variable name**: `AI` - **Variable name**: `AI`
- **Type**: Workers 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) ## 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. 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
@@ -82,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`)
@@ -7,7 +7,14 @@
## 功能说明 ## 功能说明
AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内容,智能提取重要信息,包括 邮件识别功能会自动分析收到的邮件内容,提取其中的重要信息,并支持两种互斥的识别模式
| 模式 | 识别内容 | 隐私 | 依赖 |
| ---- | -------- | ---- | ---- |
| `local`(默认) | 仅**验证码** (auth_code) | 在 Worker 内用内置规则识别,邮件内容**不会发送给任何 AI 模型** | 无,零成本 |
| `ai` | 验证码、认证链接、服务链接、订阅管理链接、其他链接 | 邮件内容会发送给你 Cloudflare 账号下的 Workers AI 模型 | Workers AI 绑定 |
`ai` 模式可识别的类型:
- **验证码** (auth_code) - OTP、安全码、确认码等 - **验证码** (auth_code) - OTP、安全码、确认码等
- **认证链接** (auth_link) - 登录、验证、激活、重置密码链接 - **认证链接** (auth_link) - 登录、验证、激活、重置密码链接
@@ -15,26 +22,55 @@ AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内
- **订阅管理链接** (subscription_link) - 退订、管理订阅等链接 - **订阅管理链接** (subscription_link) - 退订、管理订阅等链接
- **其他链接** (other_link) - 其他有价值的链接 - **其他链接** (other_link) - 其他有价值的链接
提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接。 提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接Telegram 推送与 Webhook 占位符也会复用该结果
## 配置变量 ## 配置变量
| 变量名 | 类型 | 说明 | 示例 | | 变量名 | 类型 | 说明 | 示例 |
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用 AI 邮件识别功能 | `true` | | `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用邮件识别功能(总开关,两种模式都需要) | `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` | | `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 绑定或调用模型失败时,会记录错误日志并跳过本封邮件的识别,不会改用本地规则
- `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 分析。 为避免 AI 模型 token 限制,邮件内容最大处理长度为 **4000 字符**。超过此长度的邮件内容将被截断后再进行 AI 分析。
## Workers AI 绑定 ### Workers AI 绑定
需要在 `wrangler.toml` 中配置 Workers AI 绑定: 需要在 `wrangler.toml` 中配置 Workers AI 绑定:
```toml ```toml
AI_EXTRACT_MODE = "ai"
[ai] [ai]
binding = "AI" binding = "AI"
``` ```
@@ -43,26 +79,14 @@ binding = "AI"
- **Variable name**: `AI` - **Variable name**: `AI`
- **Type**: Workers 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 提取设置** 页面配置地址白名单。白名单只控制 Workers AI 调用,不限制本地规则模式;`ai` 模式下未命中白名单的地址仍会使用本地规则尝试提取验证码。
### 配置说明 ### 配置说明
- **未启用白名单**:所有邮箱地址都可使用 AI 提取功能 - **未启用白名单**:所有邮箱地址都可使用 Workers AI 提取
- **启用白名单**:仅白名单中的邮箱地址会进行 AI 提取 - **启用白名单**:仅白名单中的邮箱地址会调用 Workers AI;未命中的地址会跳过 Workers AI,并回退到本地验证码提取
### 白名单格式 ### 白名单格式
@@ -82,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`
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "temp-mail-docs", "name": "temp-mail-docs",
"private": true, "private": true,
"version": "1.12.0", "version": "1.13.0",
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/node": "^26.4.1", "@types/node": "^26.5.1",
"vitepress": "^1.6.4", "vitepress": "^1.6.4",
"wrangler": "^4.129.0" "wrangler": "^4.131.1"
}, },
"scripts": { "scripts": {
"dev": "vitepress dev docs", "dev": "vitepress dev docs",
@@ -15,7 +15,7 @@
"deploy": "npm run build && wrangler pages deploy ./docs/.vitepress/dist --project-name=temp-mail-docs --branch production" "deploy": "npm run build && wrangler pages deploy ./docs/.vitepress/dist --project-name=temp-mail-docs --branch production"
}, },
"dependencies": { "dependencies": {
"jszip": "^3.10.1" "jszip": "^3.10.2"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39" "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }
+420 -412
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloudflare_temp_email", "name": "cloudflare_temp_email",
"version": "1.12.0", "version": "1.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -11,13 +11,13 @@
"build": "wrangler deploy --dry-run --outdir dist --minify" "build": "wrangler deploy --dry-run --outdir dist --minify"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^5.20260906.1", "@cloudflare/workers-types": "^5.20260911.1",
"@eslint/js": "9.39.1", "@eslint/js": "9.39.1",
"@types/node": "^25.9.5", "@types/node": "^25.9.6",
"eslint": "9.39.1", "eslint": "9.39.1",
"globals": "^16.5.0", "globals": "^16.5.0",
"typescript-eslint": "^8.69.0", "typescript-eslint": "^8.70.0",
"wrangler": "^4.129.0" "wrangler": "^4.131.1"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.888.0", "@aws-sdk/client-s3": "3.888.0",
@@ -27,7 +27,7 @@
"jsonpath-plus": "^10.4.0", "jsonpath-plus": "^10.4.0",
"mimetext": "^3.0.28", "mimetext": "^3.0.28",
"postal-mime": "^2.7.6", "postal-mime": "^2.7.6",
"resend": "^6.26.0", "resend": "^6.28.0",
"telegraf": "4.16.3", "telegraf": "4.16.3",
"worker-mailer": "^1.2.1" "worker-mailer": "^1.2.1"
}, },
+249 -241
View File
@@ -35,8 +35,8 @@ importers:
specifier: ^2.7.6 specifier: ^2.7.6
version: 2.7.6 version: 2.7.6
resend: resend:
specifier: ^6.26.0 specifier: ^6.28.0
version: 6.26.0(@react-email/render@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) version: 6.28.0(@react-email/render@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
telegraf: telegraf:
specifier: 4.16.3 specifier: 4.16.3
version: 4.16.3(patch_hash=4ce8461c7f21d886ddd30d13d2be4fc8c72134fd7ffa5da3b27319c883447eed) version: 4.16.3(patch_hash=4ce8461c7f21d886ddd30d13d2be4fc8c72134fd7ffa5da3b27319c883447eed)
@@ -45,14 +45,14 @@ importers:
version: 1.2.1 version: 1.2.1
devDependencies: devDependencies:
'@cloudflare/workers-types': '@cloudflare/workers-types':
specifier: ^5.20260906.1 specifier: ^5.20260911.1
version: 5.20260906.1 version: 5.20260911.1
'@eslint/js': '@eslint/js':
specifier: 9.39.1 specifier: 9.39.1
version: 9.39.1 version: 9.39.1
'@types/node': '@types/node':
specifier: ^25.9.5 specifier: ^25.9.6
version: 25.9.5 version: 25.9.6
eslint: eslint:
specifier: 9.39.1 specifier: 9.39.1
version: 9.39.1 version: 9.39.1
@@ -60,11 +60,11 @@ importers:
specifier: ^16.5.0 specifier: ^16.5.0
version: 16.5.0 version: 16.5.0
typescript-eslint: typescript-eslint:
specifier: ^8.69.0 specifier: ^8.70.0
version: 8.69.0(eslint@9.39.1)(typescript@5.4.5) version: 8.70.0(eslint@9.39.1)(typescript@5.4.5)
wrangler: wrangler:
specifier: ^4.129.0 specifier: ^4.131.1
version: 4.129.0(@cloudflare/workers-types@5.20260906.1) version: 4.131.1(@cloudflare/workers-types@5.20260911.1)(@types/node@25.9.6)
packages: packages:
@@ -252,38 +252,38 @@ packages:
workerd: workerd:
optional: true optional: true
'@cloudflare/workerd-darwin-64@1.20260903.1': '@cloudflare/workerd-darwin-64@1.20260911.1':
resolution: {integrity: sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==} resolution: {integrity: sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260903.1': '@cloudflare/workerd-darwin-arm64@1.20260911.1':
resolution: {integrity: sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==} resolution: {integrity: sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@cloudflare/workerd-linux-64@1.20260903.1': '@cloudflare/workerd-linux-64@1.20260911.1':
resolution: {integrity: sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==} resolution: {integrity: sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260903.1': '@cloudflare/workerd-linux-arm64@1.20260911.1':
resolution: {integrity: sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==} resolution: {integrity: sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@cloudflare/workerd-windows-64@1.20260903.1': '@cloudflare/workerd-windows-64@1.20260911.1':
resolution: {integrity: sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==} resolution: {integrity: sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@cloudflare/workers-types@5.20260906.1': '@cloudflare/workers-types@5.20260911.1':
resolution: {integrity: sha512-mRKC5n+9OP7tZiOu7vMv2MZ+Arh3tX4b9gVAt43WFns83LNzWchhfRs9o2IkFCnYKovHo4m4Af1P4zPQWdLx0g==} resolution: {integrity: sha512-yiAvknjulcU85B3yB4aKOn9+l+garWP+AbHgsdCFckeRYDdhZ1rPULi64BDf52R3VTaKqxi47kF+o9ZbjsCt5g==}
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
@@ -513,144 +513,144 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.2': '@img/sharp-darwin-arm64@0.35.4':
resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-darwin-x64@0.35.2': '@img/sharp-darwin-x64@0.35.4':
resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.2': '@img/sharp-freebsd-wasm32@0.35.4':
resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
os: [freebsd] os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.1': '@img/sharp-libvips-darwin-arm64@1.3.3':
resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.1': '@img/sharp-libvips-darwin-x64@1.3.3':
resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.1': '@img/sharp-libvips-linux-arm64@1.3.3':
resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-arm@1.3.1': '@img/sharp-libvips-linux-arm@1.3.3':
resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-ppc64@1.3.1': '@img/sharp-libvips-linux-ppc64@1.3.3':
resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-riscv64@1.3.1': '@img/sharp-libvips-linux-riscv64@1.3.3':
resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-s390x@1.3.1': '@img/sharp-libvips-linux-s390x@1.3.3':
resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-x64@1.3.1': '@img/sharp-libvips-linux-x64@1.3.3':
resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.3.1': '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.3.1': '@img/sharp-libvips-linuxmusl-x64@1.3.3':
resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-linux-arm64@0.35.2': '@img/sharp-linux-arm64@0.35.4':
resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-linux-arm@0.35.2': '@img/sharp-linux-arm@0.35.4':
resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@img/sharp-linux-ppc64@0.35.2': '@img/sharp-linux-ppc64@0.35.4':
resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-linux-riscv64@0.35.2': '@img/sharp-linux-riscv64@0.35.4':
resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@img/sharp-linux-s390x@0.35.2': '@img/sharp-linux-s390x@0.35.4':
resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@img/sharp-linux-x64@0.35.2': '@img/sharp-linux-x64@0.35.4':
resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-linuxmusl-arm64@0.35.2': '@img/sharp-linuxmusl-arm64@0.35.4':
resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-linuxmusl-x64@0.35.2': '@img/sharp-linuxmusl-x64@0.35.4':
resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-wasm32@0.35.2': '@img/sharp-wasm32@0.35.4':
resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.2': '@img/sharp-webcontainers-wasm32@0.35.4':
resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [wasm32] cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.2': '@img/sharp-win32-arm64@0.35.4':
resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@img/sharp-win32-ia32@0.35.2': '@img/sharp-win32-ia32@0.35.4':
resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
engines: {node: ^20.9.0} engines: {node: ^20.9.0}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@img/sharp-win32-x64@0.35.2': '@img/sharp-win32-x64@0.35.4':
resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -945,69 +945,69 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.9.5': '@types/node@25.9.6':
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} resolution: {integrity: sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==}
'@types/uuid@9.0.8': '@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
'@typescript-eslint/eslint-plugin@8.69.0': '@typescript-eslint/eslint-plugin@8.70.0':
resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.69.0 '@typescript-eslint/parser': ^8.70.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/parser@8.69.0': '@typescript-eslint/parser@8.70.0':
resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/project-service@8.69.0': '@typescript-eslint/project-service@8.70.0':
resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/scope-manager@8.69.0': '@typescript-eslint/scope-manager@8.70.0':
resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.69.0': '@typescript-eslint/tsconfig-utils@8.70.0':
resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/type-utils@8.69.0': '@typescript-eslint/type-utils@8.70.0':
resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/types@8.69.0': '@typescript-eslint/types@8.70.0':
resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.69.0': '@typescript-eslint/typescript-estree@8.70.0':
resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/utils@8.69.0': '@typescript-eslint/utils@8.70.0':
resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0' typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/visitor-keys@8.69.0': '@typescript-eslint/visitor-keys@8.70.0':
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
abort-controller@3.0.0: abort-controller@3.0.0:
@@ -1278,8 +1278,8 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
ignore@7.0.8: ignore@7.0.9:
resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
import-fresh@3.3.1: import-fresh@3.3.1:
@@ -1365,8 +1365,8 @@ packages:
mimetext@3.0.28: mimetext@3.0.28:
resolution: {integrity: sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==} resolution: {integrity: sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==}
miniflare@5.20260903.0-alpha: miniflare@5.20260911.0-alpha:
resolution: {integrity: sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==} resolution: {integrity: sha512-CRieJmvHx+7rNqnA5SKdsYsER6rfkUIE/jruIUw+fLhsQ4sORfuMtr3+FQzsQ9/y8lhk061V4Fl1DFdHiyBB6g==}
engines: {node: '>=22.0.0'} engines: {node: '>=22.0.0'}
minimatch@10.2.6: minimatch@10.2.6:
@@ -1480,8 +1480,8 @@ packages:
reflect-metadata@0.2.2: reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
resend@6.26.0: resend@6.28.0:
resolution: {integrity: sha512-vkrULozV5nGF8o7tyun/t0+qFgtPpn+cyRhVqeorhOsPUNDiSc/9p/pn8AprdHqJpYaTvftpnQgWurW++FkCdw==} resolution: {integrity: sha512-xhRMQDZRjvJQ+sWJA3wFaoeVe5CpTxJJAfHnnULX8vifjj4QQLCLRn/AfnMZ4YmVhpFkuiX4LKxZCcj54l67Yw==}
engines: {node: '>=20'} engines: {node: '>=20'}
peerDependencies: peerDependencies:
'@react-email/render': '*' '@react-email/render': '*'
@@ -1511,9 +1511,14 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
sharp@0.35.2: sharp@0.35.4:
resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
@@ -1573,8 +1578,8 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
typescript-eslint@8.69.0: typescript-eslint@8.70.0:
resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
@@ -1621,17 +1626,17 @@ packages:
worker-mailer@1.2.1: worker-mailer@1.2.1:
resolution: {integrity: sha512-gS2ei/mrpRqNs+AHmqxhT6vFPwCLw2qnz5ShmyGD0ULaU0Q9hxnFAcx9jhAip/MnD6+MjgnQu6hQQgA8mlOkVA==} resolution: {integrity: sha512-gS2ei/mrpRqNs+AHmqxhT6vFPwCLw2qnz5ShmyGD0ULaU0Q9hxnFAcx9jhAip/MnD6+MjgnQu6hQQgA8mlOkVA==}
workerd@1.20260903.1: workerd@1.20260911.1:
resolution: {integrity: sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==} resolution: {integrity: sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ==}
engines: {node: '>=16'} engines: {node: '>=16'}
hasBin: true hasBin: true
wrangler@4.129.0: wrangler@4.131.1:
resolution: {integrity: sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==} resolution: {integrity: sha512-1u5FMdJAn6UOcL02cVsIITcnHrk6mC7N+RF10EkVhPL18R/o9g5BZb4PCjByL+3AsRP5wQpppCIPHhYPRmIJwg==}
engines: {node: '>=22.0.0'} engines: {node: '>=22.0.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
'@cloudflare/workers-types': ^5.20260903.1 '@cloudflare/workers-types': ^5.20260911.1
peerDependenciesMeta: peerDependenciesMeta:
'@cloudflare/workers-types': '@cloudflare/workers-types':
optional: true optional: true
@@ -2155,28 +2160,28 @@ snapshots:
'@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/kv-asset-handler@0.5.0': {}
'@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1)': '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260911.1)':
dependencies: dependencies:
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
optionalDependencies: optionalDependencies:
workerd: 1.20260903.1 workerd: 1.20260911.1
'@cloudflare/workerd-darwin-64@1.20260903.1': '@cloudflare/workerd-darwin-64@1.20260911.1':
optional: true optional: true
'@cloudflare/workerd-darwin-arm64@1.20260903.1': '@cloudflare/workerd-darwin-arm64@1.20260911.1':
optional: true optional: true
'@cloudflare/workerd-linux-64@1.20260903.1': '@cloudflare/workerd-linux-64@1.20260911.1':
optional: true optional: true
'@cloudflare/workerd-linux-arm64@1.20260903.1': '@cloudflare/workerd-linux-arm64@1.20260911.1':
optional: true optional: true
'@cloudflare/workerd-windows-64@1.20260903.1': '@cloudflare/workerd-windows-64@1.20260911.1':
optional: true optional: true
'@cloudflare/workers-types@5.20260906.1': {} '@cloudflare/workers-types@5.20260911.1': {}
'@cspotcode/source-map-support@0.8.1': '@cspotcode/source-map-support@0.8.1':
dependencies: dependencies:
@@ -2331,108 +2336,108 @@ snapshots:
'@img/colour@1.1.0': {} '@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.2': '@img/sharp-darwin-arm64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.1 '@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true optional: true
'@img/sharp-darwin-x64@0.35.2': '@img/sharp-darwin-x64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.1 '@img/sharp-libvips-darwin-x64': 1.3.3
optional: true optional: true
'@img/sharp-freebsd-wasm32@0.35.2': '@img/sharp-freebsd-wasm32@0.35.4':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.2 '@img/sharp-wasm32': 0.35.4
optional: true optional: true
'@img/sharp-libvips-darwin-arm64@1.3.1': '@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-darwin-x64@1.3.1': '@img/sharp-libvips-darwin-x64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-arm64@1.3.1': '@img/sharp-libvips-linux-arm64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-arm@1.3.1': '@img/sharp-libvips-linux-arm@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-ppc64@1.3.1': '@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-riscv64@1.3.1': '@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-s390x@1.3.1': '@img/sharp-libvips-linux-s390x@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-x64@1.3.1': '@img/sharp-libvips-linux-x64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.1': '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.1': '@img/sharp-libvips-linuxmusl-x64@1.3.3':
optional: true optional: true
'@img/sharp-linux-arm64@0.35.2': '@img/sharp-linux-arm64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.1 '@img/sharp-libvips-linux-arm64': 1.3.3
optional: true optional: true
'@img/sharp-linux-arm@0.35.2': '@img/sharp-linux-arm@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.1 '@img/sharp-libvips-linux-arm': 1.3.3
optional: true optional: true
'@img/sharp-linux-ppc64@0.35.2': '@img/sharp-linux-ppc64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.1 '@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true optional: true
'@img/sharp-linux-riscv64@0.35.2': '@img/sharp-linux-riscv64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.1 '@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true optional: true
'@img/sharp-linux-s390x@0.35.2': '@img/sharp-linux-s390x@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.1 '@img/sharp-libvips-linux-s390x': 1.3.3
optional: true optional: true
'@img/sharp-linux-x64@0.35.2': '@img/sharp-linux-x64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.1 '@img/sharp-libvips-linux-x64': 1.3.3
optional: true optional: true
'@img/sharp-linuxmusl-arm64@0.35.2': '@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1 '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true optional: true
'@img/sharp-linuxmusl-x64@0.35.2': '@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.1 '@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true optional: true
'@img/sharp-wasm32@0.35.2': '@img/sharp-wasm32@0.35.4':
dependencies: dependencies:
'@emnapi/runtime': 1.11.3 '@emnapi/runtime': 1.11.3
optional: true optional: true
'@img/sharp-webcontainers-wasm32@0.35.2': '@img/sharp-webcontainers-wasm32@0.35.4':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.2 '@img/sharp-wasm32': 0.35.4
optional: true optional: true
'@img/sharp-win32-arm64@0.35.2': '@img/sharp-win32-arm64@0.35.4':
optional: true optional: true
'@img/sharp-win32-ia32@0.35.2': '@img/sharp-win32-ia32@0.35.4':
optional: true optional: true
'@img/sharp-win32-x64@0.35.2': '@img/sharp-win32-x64@0.35.4':
optional: true optional: true
'@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/resolve-uri@3.1.2': {}
@@ -2822,63 +2827,63 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@25.9.5': '@types/node@25.9.6':
dependencies: dependencies:
undici-types: 7.24.6 undici-types: 7.24.6
'@types/uuid@9.0.8': {} '@types/uuid@9.0.8': {}
'@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.1)(typescript@5.4.5))(eslint@9.39.1)(typescript@5.4.5)': '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.1)(typescript@5.4.5))(eslint@9.39.1)(typescript@5.4.5)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.69.0(eslint@9.39.1)(typescript@5.4.5) '@typescript-eslint/parser': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
'@typescript-eslint/scope-manager': 8.69.0 '@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/type-utils': 8.69.0(eslint@9.39.1)(typescript@5.4.5) '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
'@typescript-eslint/utils': 8.69.0(eslint@9.39.1)(typescript@5.4.5) '@typescript-eslint/utils': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
'@typescript-eslint/visitor-keys': 8.69.0 '@typescript-eslint/visitor-keys': 8.70.0
eslint: 9.39.1 eslint: 9.39.1
ignore: 7.0.8 ignore: 7.0.9
natural-compare: 1.4.0 natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.4.5) ts-api-utils: 2.5.0(typescript@5.4.5)
typescript: 5.4.5 typescript: 5.4.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.69.0(eslint@9.39.1)(typescript@5.4.5)': '@typescript-eslint/parser@8.70.0(eslint@9.39.1)(typescript@5.4.5)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.69.0 '@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5) '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
'@typescript-eslint/visitor-keys': 8.69.0 '@typescript-eslint/visitor-keys': 8.70.0
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.1 eslint: 9.39.1
typescript: 5.4.5 typescript: 5.4.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.69.0(typescript@5.4.5)': '@typescript-eslint/project-service@8.70.0(typescript@5.4.5)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.4.5) '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.4.5)
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.70.0
debug: 4.4.3 debug: 4.4.3
typescript: 5.4.5 typescript: 5.4.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.69.0': '@typescript-eslint/scope-manager@8.70.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.70.0
'@typescript-eslint/visitor-keys': 8.69.0 '@typescript-eslint/visitor-keys': 8.70.0
'@typescript-eslint/tsconfig-utils@8.69.0(typescript@5.4.5)': '@typescript-eslint/tsconfig-utils@8.70.0(typescript@5.4.5)':
dependencies: dependencies:
typescript: 5.4.5 typescript: 5.4.5
'@typescript-eslint/type-utils@8.69.0(eslint@9.39.1)(typescript@5.4.5)': '@typescript-eslint/type-utils@8.70.0(eslint@9.39.1)(typescript@5.4.5)':
dependencies: dependencies:
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5) '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
'@typescript-eslint/utils': 8.69.0(eslint@9.39.1)(typescript@5.4.5) '@typescript-eslint/utils': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.1 eslint: 9.39.1
ts-api-utils: 2.5.0(typescript@5.4.5) ts-api-utils: 2.5.0(typescript@5.4.5)
@@ -2886,14 +2891,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/types@8.69.0': {} '@typescript-eslint/types@8.70.0': {}
'@typescript-eslint/typescript-estree@8.69.0(typescript@5.4.5)': '@typescript-eslint/typescript-estree@8.70.0(typescript@5.4.5)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.69.0(typescript@5.4.5) '@typescript-eslint/project-service': 8.70.0(typescript@5.4.5)
'@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.4.5) '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.4.5)
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.70.0
'@typescript-eslint/visitor-keys': 8.69.0 '@typescript-eslint/visitor-keys': 8.70.0
debug: 4.4.3 debug: 4.4.3
minimatch: 10.2.6 minimatch: 10.2.6
semver: 7.8.5 semver: 7.8.5
@@ -2903,20 +2908,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.69.0(eslint@9.39.1)(typescript@5.4.5)': '@typescript-eslint/utils@8.70.0(eslint@9.39.1)(typescript@5.4.5)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.1) '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.1)
'@typescript-eslint/scope-manager': 8.69.0 '@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5) '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
eslint: 9.39.1 eslint: 9.39.1
typescript: 5.4.5 typescript: 5.4.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/visitor-keys@8.69.0': '@typescript-eslint/visitor-keys@8.70.0':
dependencies: dependencies:
'@typescript-eslint/types': 8.69.0 '@typescript-eslint/types': 8.70.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
abort-controller@3.0.0: abort-controller@3.0.0:
@@ -3209,7 +3214,7 @@ snapshots:
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.8: {} ignore@7.0.9: {}
import-fresh@3.3.1: import-fresh@3.3.1:
dependencies: dependencies:
@@ -3287,15 +3292,16 @@ snapshots:
js-base64: 3.9.3 js-base64: 3.9.3
mime-types: 2.1.35 mime-types: 2.1.35
miniflare@5.20260903.0-alpha: miniflare@5.20260911.0-alpha(@types/node@25.9.6):
dependencies: dependencies:
'@cspotcode/source-map-support': 0.8.1 '@cspotcode/source-map-support': 0.8.1
sharp: 0.35.2 sharp: 0.35.4(@types/node@25.9.6)
undici: 7.29.0 undici: 7.29.0
workerd: 1.20260903.1 workerd: 1.20260911.1
ws: 8.21.0 ws: 8.21.0
youch: 4.1.0-beta.10 youch: 4.1.0-beta.10
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node'
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
@@ -3395,7 +3401,7 @@ snapshots:
reflect-metadata@0.2.2: {} reflect-metadata@0.2.2: {}
resend@6.26.0(@react-email/render@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): resend@6.28.0(@react-email/render@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
dependencies: dependencies:
postal-mime: 2.7.5 postal-mime: 2.7.5
standardwebhooks: 1.0.0 standardwebhooks: 1.0.0
@@ -3422,37 +3428,38 @@ snapshots:
semver@7.8.5: {} semver@7.8.5: {}
sharp@0.35.2: sharp@0.35.4(@types/node@25.9.6):
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.8.5 semver: 7.8.5
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.2 '@img/sharp-darwin-arm64': 0.35.4
'@img/sharp-darwin-x64': 0.35.2 '@img/sharp-darwin-x64': 0.35.4
'@img/sharp-freebsd-wasm32': 0.35.2 '@img/sharp-freebsd-wasm32': 0.35.4
'@img/sharp-libvips-darwin-arm64': 1.3.1 '@img/sharp-libvips-darwin-arm64': 1.3.3
'@img/sharp-libvips-darwin-x64': 1.3.1 '@img/sharp-libvips-darwin-x64': 1.3.3
'@img/sharp-libvips-linux-arm': 1.3.1 '@img/sharp-libvips-linux-arm': 1.3.3
'@img/sharp-libvips-linux-arm64': 1.3.1 '@img/sharp-libvips-linux-arm64': 1.3.3
'@img/sharp-libvips-linux-ppc64': 1.3.1 '@img/sharp-libvips-linux-ppc64': 1.3.3
'@img/sharp-libvips-linux-riscv64': 1.3.1 '@img/sharp-libvips-linux-riscv64': 1.3.3
'@img/sharp-libvips-linux-s390x': 1.3.1 '@img/sharp-libvips-linux-s390x': 1.3.3
'@img/sharp-libvips-linux-x64': 1.3.1 '@img/sharp-libvips-linux-x64': 1.3.3
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1 '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
'@img/sharp-libvips-linuxmusl-x64': 1.3.1 '@img/sharp-libvips-linuxmusl-x64': 1.3.3
'@img/sharp-linux-arm': 0.35.2 '@img/sharp-linux-arm': 0.35.4
'@img/sharp-linux-arm64': 0.35.2 '@img/sharp-linux-arm64': 0.35.4
'@img/sharp-linux-ppc64': 0.35.2 '@img/sharp-linux-ppc64': 0.35.4
'@img/sharp-linux-riscv64': 0.35.2 '@img/sharp-linux-riscv64': 0.35.4
'@img/sharp-linux-s390x': 0.35.2 '@img/sharp-linux-s390x': 0.35.4
'@img/sharp-linux-x64': 0.35.2 '@img/sharp-linux-x64': 0.35.4
'@img/sharp-linuxmusl-arm64': 0.35.2 '@img/sharp-linuxmusl-arm64': 0.35.4
'@img/sharp-linuxmusl-x64': 0.35.2 '@img/sharp-linuxmusl-x64': 0.35.4
'@img/sharp-webcontainers-wasm32': 0.35.2 '@img/sharp-webcontainers-wasm32': 0.35.4
'@img/sharp-win32-arm64': 0.35.2 '@img/sharp-win32-arm64': 0.35.4
'@img/sharp-win32-ia32': 0.35.2 '@img/sharp-win32-ia32': 0.35.4
'@img/sharp-win32-x64': 0.35.2 '@img/sharp-win32-x64': 0.35.4
'@types/node': 25.9.6
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
@@ -3514,12 +3521,12 @@ snapshots:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
typescript-eslint@8.69.0(eslint@9.39.1)(typescript@5.4.5): typescript-eslint@8.70.0(eslint@9.39.1)(typescript@5.4.5):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.1)(typescript@5.4.5))(eslint@9.39.1)(typescript@5.4.5) '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.1)(typescript@5.4.5))(eslint@9.39.1)(typescript@5.4.5)
'@typescript-eslint/parser': 8.69.0(eslint@9.39.1)(typescript@5.4.5) '@typescript-eslint/parser': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5) '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
'@typescript-eslint/utils': 8.69.0(eslint@9.39.1)(typescript@5.4.5) '@typescript-eslint/utils': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
eslint: 9.39.1 eslint: 9.39.1
typescript: 5.4.5 typescript: 5.4.5
transitivePeerDependencies: transitivePeerDependencies:
@@ -3556,28 +3563,29 @@ snapshots:
worker-mailer@1.2.1: {} worker-mailer@1.2.1: {}
workerd@1.20260903.1: workerd@1.20260911.1:
optionalDependencies: optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260903.1 '@cloudflare/workerd-darwin-64': 1.20260911.1
'@cloudflare/workerd-darwin-arm64': 1.20260903.1 '@cloudflare/workerd-darwin-arm64': 1.20260911.1
'@cloudflare/workerd-linux-64': 1.20260903.1 '@cloudflare/workerd-linux-64': 1.20260911.1
'@cloudflare/workerd-linux-arm64': 1.20260903.1 '@cloudflare/workerd-linux-arm64': 1.20260911.1
'@cloudflare/workerd-windows-64': 1.20260903.1 '@cloudflare/workerd-windows-64': 1.20260911.1
wrangler@4.129.0(@cloudflare/workers-types@5.20260906.1): wrangler@4.131.1(@cloudflare/workers-types@5.20260911.1)(@types/node@25.9.6):
dependencies: dependencies:
'@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260903.1) '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260911.1)
blake3-wasm: 2.1.5 blake3-wasm: 2.1.5
esbuild: 0.28.1 esbuild: 0.28.1
miniflare: 5.20260903.0-alpha miniflare: 5.20260911.0-alpha(@types/node@25.9.6)
path-to-regexp: 6.3.0 path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24 unenv: 2.0.0-rc.24
workerd: 1.20260903.1 workerd: 1.20260911.1
optionalDependencies: optionalDependencies:
'@cloudflare/workers-types': 5.20260906.1 '@cloudflare/workers-types': 5.20260911.1
fsevents: 2.3.3 fsevents: 2.3.3
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node'
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
+1 -1
View File
@@ -1,5 +1,5 @@
export const CONSTANTS = { export const CONSTANTS = {
VERSION: 'v' + '1.12.0', VERSION: 'v' + '1.13.0',
// DB Version // DB Version
DB_VERSION_KEY: 'db_version', DB_VERSION_KEY: 'db_version',
+54 -43
View File
@@ -1,13 +1,14 @@
/** /**
* AI Email Extraction Module * AI Email Extraction Module
* *
* This module provides email content analysis using Cloudflare Workers AI. * This module provides email content analysis, either with built-in local rules
* It extracts important information like verification codes, authentication links, * (verification codes only) or with Cloudflare Workers AI, which also extracts
* service links, and subscription management links from email content. * authentication links, service links, and subscription management links.
*/ */
import { commonParseMail } from "../common"; import { commonParseMail } from "../common";
import { extractCode } from "./extract_code"; import { extractCode, joinSubjectAndBody } from "./extract_code";
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";
@@ -141,7 +142,7 @@ async function extractWithCloudflareAI(
/** /**
* Persist an extraction result to the raw_mails metadata column. * 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 env - Cloudflare Workers environment bindings
* @param message_id - The email message ID * @param message_id - The email message ID
@@ -226,11 +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.
* Uses Cloudflare Workers AI when the `AI` binding is available; otherwise falls back * `AI_EXTRACT_MODE` selects the preferred extractor:
* to a built-in regex extractor that surfaces verification codes only. * - `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
* *
* @param parsedEmailContext - The parsed email context * @param parsedEmailContext - The parsed email context
* @param env - Cloudflare Workers environment bindings * @param env - Cloudflare Workers environment bindings
@@ -250,53 +264,50 @@ export async function extractEmailInfo(
return null; 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;
}
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) { // Parse email to get content (shared by both modes)
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 parsedEmail = await commonParseMail(parsedEmailContext);
const emailContent = getEmailContentForExtract(parsedEmail); const emailContent = getEmailContentForExtract(parsedEmail);
if (!emailContent) { 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; return null;
} }
// Fallback: when no Workers AI binding is available, use a built-in regex if (!emailContent) {
// extractor so self-hosted deployments without Workers AI still surface return null;
// 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) // Truncate content if too long (max 4000 characters to avoid token limits)
+189 -45
View File
@@ -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. * Extracts verification codes from email text without calling any AI model,
* Covers common formats across English, Chinese, Japanese and Korean. * 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 * Pipeline:
* self-hosted deployments without Workers AI still surface verification codes. * 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): * Every candidate must look like a code: 4-8 digits (not a year or YYYYMMDD
* 1. Prefer codes that appear after an explicit code keyword. * date), or 4-10 letters and digits containing at least one digit. Digits that
* 2. Reject plausible dates/years (e.g. "2026", "20260411") — too common as * belong to amounts, decimals, times, phone numbers or longer numbers are never
* date markers in subject lines, almost never a real verification code. * taken as codes.
* 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 * Some keyword lists and test messages are adapted from 2FHey
* AND the digits don't look like a year or YYYYMMDD date. * (https://github.com/SoFriendly/2fhey, CC0-1.0).
*/ */
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 { export function extractCode(text: string): string | null {
// DELIM: at least one explicit delimiter must follow the code keyword before if (!text) return null;
// the code itself. Allowed delimiters: an ASCII or full-width colon, the word const normalized = normalizeText(text.slice(0, MAX_TEXT_LENGTH));
// "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 for (const pattern of PATTERNS) {
const CN_JA_KO_KW = '验证码|认证码|确认码|認証コード|인증\\s*코드|코드'; const code = findCode(normalized, pattern);
const EN_KW = 'verification\\s*code|confirm(?:ation)?\\s*code|security\\s*code|passcode|OTP|pin\\s*code'; if (code) return 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];
} }
// Fallback: standalone digit sequence, but reject year/date patterns if (!VERIFY_CONTEXT.test(normalized)) return null;
const standaloneMatch = text.match(/(?:^|\s)(\d{4,12})(?:\s|$|\.|,)/m);
if (standaloneMatch?.[1] && !looksLikeDate(standaloneMatch[1])) {
return standaloneMatch[1];
}
for (const pattern of FALLBACK_PATTERNS) {
const code = findCode(normalized, pattern);
if (code) return code;
}
return null; 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 "1234565分钟" 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? * Heuristic: does this digit sequence look like a date/year we should reject?
* - 4 digits matching 19xx or 20xx → year (e.g. 2026) * - 4 digits matching 19xx or 20xx → year (e.g. 2026)
+29
View File
@@ -0,0 +1,29 @@
/**
* 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;
}
+1
View File
@@ -118,6 +118,7 @@ type Bindings = {
// AI extraction config // AI extraction config
ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined
AI_EXTRACT_MODE: string | undefined
AI_EXTRACT_MODEL: string | undefined AI_EXTRACT_MODEL: string | undefined
// gzip compression for raw_mails // gzip compression for raw_mails
+5 -2
View File
@@ -150,11 +150,14 @@ ENABLE_AUTO_REPLY = false
# REMOVE_ALL_ATTACHMENT = true # REMOVE_ALL_ATTACHMENT = true
# enable gzip compressed email storage in raw_blob column (run db_migration first) # enable gzip compressed email storage in raw_blob column (run db_migration first)
# ENABLE_MAIL_GZIP = true # 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 # 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 # 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"
# 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" # AI_EXTRACT_MODEL = "@cf/meta/llama-3.1-8b-instruct-fast"
# Calling other woker to process email # Calling other woker to process email
# ENABLE_ANOTHER_WORKER = false # ENABLE_ANOTHER_WORKER = false