mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-17 13:24:13 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67c6c1f82c | ||
|
|
1e054578d8 | ||
|
|
cea2a8c70d | ||
|
|
25ca019f6a | ||
|
|
952ab6f5d6 | ||
|
|
39db6bad43 | ||
|
|
5e4823a115 | ||
|
|
479bb94525 |
@@ -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
|
||||
```
|
||||
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>`.
|
||||
- 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>`.
|
||||
- Always include the cache-clearing discussion 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`)
|
||||
- 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
|
||||
|
||||
- feat: |模块| 描述
|
||||
- 设置对应环境变量开启。
|
||||
- 启用前执行对应迁移 SQL,或在已验证的 Admin 数据库页面升级;不使用此可选功能可跳过。
|
||||
- 简述启用后的行为。
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -21,6 +24,20 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
|
||||
|
||||
- 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)
|
||||
|
||||
<details>
|
||||
@@ -36,6 +53,8 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
|
||||
## Notes
|
||||
|
||||
- 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 are sorted by PR number ascending
|
||||
- The cache clearing discussion link is always included
|
||||
|
||||
+17
-1
@@ -6,10 +6,26 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</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
|
||||
|
||||
- feat: |Webhook| 测试弹框支持随机邮件或指定邮件 ID,校验请求体及邮箱归属并适配现有前端语言及中英文错误提示
|
||||
- feat: |Webhook| 支持无需 S3 的多附件签名链接、纯 URL 与 Markdown 链接列表,签名兼容大小写,绑定本次入库邮件并保留下载文件名,关闭 Webhook 时禁止附件下载(issue #1142)
|
||||
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
|
||||
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
|
||||
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
|
||||
|
||||
+17
-1
@@ -6,10 +6,26 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</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
|
||||
|
||||
- feat: |Webhook| Support random or specified email IDs in the test dialog, with request-body validation, mailbox ownership checks, existing UI languages and Chinese/English errors
|
||||
- feat: |Webhook| Support signed attachment URLs without S3, plain URL and Markdown link lists, with case-insensitive signatures bound to the inserted email and original download filenames; deny attachment downloads when Webhook is disabled (issue #1142)
|
||||
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
|
||||
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
|
||||
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
|
||||
|
||||
@@ -9,5 +9,7 @@ COPY e2e/package.json e2e/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY e2e/ .
|
||||
# Dependency-free worker modules covered by unit tests in tests/unit/
|
||||
COPY worker/src/email/extract_code.ts worker/src/email/extract_mode.ts /app/worker/src/email/
|
||||
|
||||
ENTRYPOINT ["/app/e2e/scripts/docker-entrypoint.sh"]
|
||||
|
||||
@@ -31,8 +31,8 @@ const seedMail = async (request: Request, env: Bindings) => {
|
||||
|
||||
// Exercises the real email() handler with a mock ForwardableEmailMessage.
|
||||
const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => {
|
||||
const { from, to, raw, ai_extract_result } = await request.json<{
|
||||
from: string; to: string; raw: string; ai_extract_result?: unknown;
|
||||
const { from, to, raw, ai_extract_result, extract_mode } = await request.json<{
|
||||
from: string; to: string; raw: string; ai_extract_result?: unknown; extract_mode?: string;
|
||||
}>();
|
||||
if (!from || !to || !raw) {
|
||||
return new Response("from, to and raw are required", { status: 400 });
|
||||
@@ -60,11 +60,12 @@ const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContex
|
||||
const { email: emailHandler } = await import('../../worker/src/email');
|
||||
const aiExtractEnvOverrides: Partial<Bindings> = {
|
||||
ENABLE_AI_EMAIL_EXTRACT: true,
|
||||
AI_EXTRACT_MODE: extract_mode ?? 'ai',
|
||||
AI: {
|
||||
run: async () => ({ response: ai_extract_result })
|
||||
} as unknown as Ai,
|
||||
};
|
||||
const emailEnv = ai_extract_result
|
||||
const emailEnv = ai_extract_result || extract_mode !== undefined
|
||||
? { ...env, ...aiExtractEnvOverrides }
|
||||
: env;
|
||||
await emailHandler(mockMessage, emailEnv, ctx);
|
||||
|
||||
@@ -30,6 +30,8 @@ DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_USER_ROLE = "admin"
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
BACKEND_URL = "http://worker:8787/"
|
||||
REMOVE_EXCEED_SIZE_ATTACHMENT = true
|
||||
CLEANUP_BATCH_SIZE = 10
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
|
||||
@@ -21,6 +21,8 @@ ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
BACKEND_URL = "http://worker-env-off:8790"
|
||||
REMOVE_ALL_ATTACHMENT = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,7 @@ ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
BACKEND_URL = "http://worker-gzip:8788"
|
||||
ENABLE_MAIL_GZIP = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
|
||||
Generated
+18
-15
@@ -6,8 +6,8 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.7.8",
|
||||
"nodemailer": "^10.0.0"
|
||||
"imapflow": "^2.0.2",
|
||||
"nodemailer": "^10.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0",
|
||||
@@ -114,9 +114,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.7.8",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
|
||||
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.0.2.tgz",
|
||||
"integrity": "sha512-jrWWecWFifN3Feum2MqaTUupNeYSAdPEltKuwGdr8IPdxW72NZXf7ObVUfTWoUH2MPrli0q7SZm9pRt8ob/9XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.16",
|
||||
@@ -126,13 +126,16 @@
|
||||
"libmime": "5.4.3",
|
||||
"libqp": "2.1.1",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.9"
|
||||
"socks": "2.8.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -163,9 +166,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
|
||||
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
|
||||
"version": "10.0.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.9.tgz",
|
||||
"integrity": "sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -303,9 +306,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"version": "2.8.10",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
|
||||
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.7.8",
|
||||
"nodemailer": "^10.0.0"
|
||||
"imapflow": "^2.0.2",
|
||||
"nodemailer": "^10.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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('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);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import { createHmac, randomUUID } from 'node:crypto';
|
||||
import { createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const variants = [
|
||||
{ name: 'on', url: process.env.WORKER_URL!, removeAll: false, removeLarge: true },
|
||||
{ name: 'gzip', url: process.env.WORKER_GZIP_URL!, removeAll: false, removeLarge: false },
|
||||
{ name: 'off', url: process.env.WORKER_URL_ENV_OFF!, removeAll: true, removeLarge: false },
|
||||
];
|
||||
|
||||
test('attachment downloads are forbidden when webhook is disabled', async ({ request }) => {
|
||||
const url = process.env.WORKER_URL_SITE_PASSWORD;
|
||||
expect(url, 'Worker with webhook disabled must be configured in CI').toBeTruthy();
|
||||
const expires = Math.floor(Date.now() / 1000) + 600;
|
||||
const response = await request.get(`${url}/open_api/a/1/0/${expires}/${'a'.repeat(52)}`, {
|
||||
headers: { 'x-lang': 'en' },
|
||||
});
|
||||
expect(response.status()).toBe(403);
|
||||
expect(await response.text()).toBe('Webhook is not enabled, please contact the administrator');
|
||||
});
|
||||
|
||||
for (const variant of variants) {
|
||||
test.describe(`Webhook attachments: ${variant.name}`, () => {
|
||||
test('links, expiry, ownership binding, deletion and removal settings', async ({ request }) => {
|
||||
test.setTimeout(60_000);
|
||||
expect(variant.url, 'Worker variant must be configured in CI').toBeTruthy();
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'attachments', undefined, variant.url);
|
||||
const jwtSecret = variant.removeAll ? 'e2e-test-secret-key-env-off' : 'e2e-test-secret-key';
|
||||
const headers = { Authorization: `Bearer ${jwt}` };
|
||||
const payloads: any[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', chunk => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
payloads.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.writeHead(200).end();
|
||||
} catch {
|
||||
res.writeHead(400).end();
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '0.0.0.0', resolve));
|
||||
const port = (server.address() as import('node:net').AddressInfo).port;
|
||||
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
|
||||
|
||||
try {
|
||||
const settings = {
|
||||
enabled: true, url: `http://${hostname}:${port}`, method: 'POST',
|
||||
headers: '{"Content-Type":"application/json"}',
|
||||
body: '{"id":"${id}","ai":${aiExtract},"attachments":${attachments},"text":"${parsedText}","links":"${attachmentLinks}","markdownLinks":"${attachmentMarkdownLinks}"}',
|
||||
};
|
||||
expect((await request.post(`${variant.url}/api/webhook/settings`, { headers, data: settings })).ok()).toBe(true);
|
||||
|
||||
const receive = async (
|
||||
files: { name: string, type: string, content: string }[], large = false,
|
||||
messageId: string | null = `<${randomUUID()}@test>`, waitForWebhook = true,
|
||||
) => {
|
||||
const boundary = randomUUID();
|
||||
const raw = [
|
||||
'From: sender@test.example.com', `To: ${address}`, 'Subject: Attachment coverage',
|
||||
...(messageId === null ? [] : [`Message-ID: ${messageId}`]), 'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`, '',
|
||||
`--${boundary}`, 'Content-Type: text/plain; charset=utf-8', '',
|
||||
'Preserved body: "quoted" \\path $& ${from}' + (large ? 'x'.repeat(2 * 1024 * 1024) : ''),
|
||||
...files.flatMap(file => [
|
||||
`--${boundary}`, `Content-Type: ${file.type}`,
|
||||
`Content-Disposition: attachment; filename="${file.name}"`,
|
||||
'Content-Transfer-Encoding: base64', '', Buffer.from(file.content).toString('base64'),
|
||||
]), `--${boundary}--`,
|
||||
].join('\r\n');
|
||||
const count = payloads.length;
|
||||
const result = await request.post(`${variant.url}/__test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to: address, raw },
|
||||
});
|
||||
expect(result.ok()).toBe(true);
|
||||
expect((await result.json()).success).toBe(true);
|
||||
if (!waitForWebhook) return;
|
||||
await expect.poll(() => payloads.length).toBe(count + 1);
|
||||
return payloads[count];
|
||||
};
|
||||
|
||||
const files = [
|
||||
{ name: 'first.png', type: 'image/png', content: 'first bytes' },
|
||||
{ name: "报告 (copy)'!.txt", type: 'text/plain', content: 'second bytes' },
|
||||
{ name: 'third.svg', type: 'image/svg+xml', content: '<svg onload="alert(1)"/>' },
|
||||
];
|
||||
const payload = await receive(files);
|
||||
expect(payload.text).toContain('Preserved body: "quoted" \\path $& ${from}');
|
||||
expect(payload.attachments).toHaveLength(variant.removeAll ? 0 : files.length);
|
||||
expect(payload.links).toBe(payload.attachments.map((a: any) => a.url).join('\n'));
|
||||
const markdownNames = ['first.png', "报告 \\(copy\\)'\\!.txt", 'third.svg'];
|
||||
expect(payload.markdownLinks).toBe(payload.attachments.map((a: any, index: number) => `[${markdownNames[index]}](${a.url})`).join('\n'));
|
||||
|
||||
const testMarkdownMessage = async (mailId: number, expectedFiles: typeof files) => {
|
||||
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
|
||||
const count = payloads.length;
|
||||
const result = await request.post(`${variant.url}${endpoint}`, {
|
||||
headers,
|
||||
data: {
|
||||
...settings, mail_id: mailId,
|
||||
body: JSON.stringify({ msgtype: 'markdown', markdown: {
|
||||
content: '**主题:** ${subject}\n\n${parsedText}\n\n附件:\n${attachmentMarkdownLinks}\n\n下载地址:\n${attachmentLinks}',
|
||||
} }),
|
||||
},
|
||||
});
|
||||
expect(result.ok()).toBe(true);
|
||||
await expect.poll(() => payloads.length).toBe(count + 1);
|
||||
const message = payloads[count];
|
||||
expect(Object.keys(message)).toEqual(['msgtype', 'markdown']);
|
||||
expect(Object.keys(message.markdown)).toEqual(['content']);
|
||||
expect(message.msgtype).toBe('markdown');
|
||||
const content = message.markdown.content;
|
||||
expect(typeof content).toBe('string');
|
||||
expect(content).toContain('**主题:** Attachment coverage\n\n');
|
||||
expect(content).toContain('Preserved body: "quoted" \\path $& ${from}');
|
||||
const [markdownLinks, plainLinks] = content.split('\n\n附件:\n')[1].split('\n\n下载地址:\n');
|
||||
const urls = plainLinks ? plainLinks.split('\n') : [];
|
||||
expect(urls).toHaveLength(expectedFiles.length);
|
||||
expect(markdownLinks).toBe(urls.map((url: string, index: number) => `[${markdownNames[index]}](${url})`).join('\n'));
|
||||
for (const [index, url] of urls.entries()) {
|
||||
expect(new URL(url).pathname.split('/')[3]).toBe(String(mailId));
|
||||
const response = await request.get(url);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.text()).toBe(expectedFiles[index].content);
|
||||
}
|
||||
}
|
||||
};
|
||||
await testMarkdownMessage(Number(payload.id), variant.removeAll ? [] : files);
|
||||
|
||||
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
|
||||
const count = payloads.length;
|
||||
expect((await request.post(`${variant.url}${endpoint}`, { headers, data: settings })).ok()).toBe(true);
|
||||
await expect.poll(() => payloads.length).toBe(count + 1);
|
||||
const preview = payloads[count];
|
||||
expect(preview.ai).toBeNull();
|
||||
expect(Array.isArray(preview.attachments)).toBe(true);
|
||||
if (endpoint.startsWith('/api/')) {
|
||||
expect(preview.attachments).toHaveLength(variant.removeAll ? 0 : files.length);
|
||||
}
|
||||
for (const attachment of preview.attachments) {
|
||||
expect((await request.get(attachment.url)).status()).toBe(200);
|
||||
}
|
||||
}
|
||||
|
||||
const detail = await request.get(`${variant.url}/api/mail/${payload.id}`, { headers });
|
||||
expect(detail.ok()).toBe(true);
|
||||
const row = await detail.json();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sign = (index: number, expires = now + 600, id = Number(payload.id), recipient = address, created = row.created_at, secret = jwtSecret) => {
|
||||
const digest = createHmac('sha256', secret).update(JSON.stringify([
|
||||
'webhook-attachment-v1', id, recipient, created, expires, index,
|
||||
])).digest();
|
||||
const bits = [...digest].map(byte => byte.toString(2).padStart(8, '0')).join('');
|
||||
const signature = bits.match(/.{1,5}/g)!.map(group =>
|
||||
'abcdefghijklmnopqrstuvwxyz234567'[parseInt(group.padEnd(5, '0'), 2)]
|
||||
).join('');
|
||||
return `/open_api/a/${id}/${index}/${expires}/${signature}`;
|
||||
};
|
||||
const get = (path: string) => request.get(new URL(path, variant.url).href);
|
||||
|
||||
for (const [index, attachment] of payload.attachments.entries()) {
|
||||
expect(attachment.filename).toBe(files[index].name);
|
||||
expect(attachment.mimeType).toBe(files[index].type);
|
||||
expect(new URL(attachment.url).origin).toBe(new URL(variant.url).origin);
|
||||
const signedUrl = new URL(attachment.url);
|
||||
const signature = signedUrl.pathname.split('/').at(-1)!;
|
||||
expect(signature).toMatch(/^[a-z2-7]{51}[aq]$/);
|
||||
signedUrl.pathname = signedUrl.pathname.replace(signature, signature.toUpperCase());
|
||||
expect((await get(signedUrl.href)).status()).toBe(200);
|
||||
signedUrl.pathname = signedUrl.pathname.replace(signature.toUpperCase(),
|
||||
[...signature].map((character, index) => index % 2 ? character.toUpperCase() : character).join(''));
|
||||
expect((await get(signedUrl.href)).status()).toBe(200);
|
||||
const response = await get(attachment.url);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.text()).toBe(files[index].content);
|
||||
expect(response.headers()['cache-control']).toBe('no-store');
|
||||
expect(response.headers()['x-content-type-options']).toBe('nosniff');
|
||||
const filename = encodeURIComponent(files[index].name).replace(/[!'()*]/g,
|
||||
character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
expect(response.headers()['content-disposition']).toBe(index === 0 ? 'inline' : `attachment; filename*=UTF-8''${filename}`);
|
||||
expect(response.headers()['content-type']).toBe(index === 0 ? 'image/png' : 'application/octet-stream');
|
||||
}
|
||||
|
||||
expect((await get(sign(0))).status()).toBe(variant.removeAll ? 404 : 200);
|
||||
for (const path of [
|
||||
sign(0, now - 1), sign(0, now + 86460), sign(-1), sign(999),
|
||||
sign(0, now + 600, Number(payload.id), 'other@test.example.com'),
|
||||
sign(0, now + 600, Number(payload.id), address, '2000-01-01 00:00:00'),
|
||||
sign(0, now + 600, Number(payload.id), address, row.created_at, 'wrong-secret'),
|
||||
sign(0, now + 600, Number.MAX_SAFE_INTEGER),
|
||||
`/open_api/a/${payload.id}/invalid/${now + 600}/${'A'.repeat(52)}`,
|
||||
`/open_api/a/${payload.id}/0/${now + 600}/${'0'.repeat(52)}`,
|
||||
`/open_api/a/${payload.id}/0/${now + 600}/${'a'.repeat(51)}b`,
|
||||
`/open_api/a/${payload.id}/0/${now + 600}/short`,
|
||||
]) {
|
||||
expect((await get(path)).status(), path).toBe(404);
|
||||
}
|
||||
|
||||
const empty = await receive([]);
|
||||
expect(empty.attachments).toEqual([]);
|
||||
expect(empty.links).toBe('');
|
||||
expect(empty.markdownLinks).toBe('');
|
||||
await testMarkdownMessage(Number(empty.id), []);
|
||||
const emptyRow = await (await request.get(`${variant.url}/api/mail/${empty.id}`, { headers })).json();
|
||||
expect((await get(sign(0, now + 600, Number(empty.id), address, emptyRow.created_at))).status()).toBe(404);
|
||||
const deleted = await request.delete(`${variant.url}/admin/mails/${payload.id}`);
|
||||
expect(deleted.ok()).toBe(true);
|
||||
expect((await get(sign(0))).status()).toBe(404);
|
||||
|
||||
if (variant.removeLarge) {
|
||||
const large = await receive(files, true);
|
||||
expect(large.attachments).toEqual([]);
|
||||
expect(large.text).toContain('Preserved body');
|
||||
const largeRow = await (await request.get(`${variant.url}/api/mail/${large.id}`, { headers })).json();
|
||||
expect((await get(sign(0, now + 600, Number(large.id), address, largeRow.created_at))).status()).toBe(404);
|
||||
}
|
||||
|
||||
if (!variant.removeAll) {
|
||||
for (const messageId of [`<${randomUUID()}@duplicate.test>`, null]) {
|
||||
const count = payloads.length;
|
||||
const names = Array.from({ length: 4 }, () => `${randomUUID()}.txt`);
|
||||
await Promise.all(names.map(name => receive([
|
||||
{ name, type: 'text/plain', content: name },
|
||||
], false, messageId, false)));
|
||||
await expect.poll(() => payloads.length).toBe(count + names.length);
|
||||
const received = payloads.slice(count);
|
||||
expect(new Set(received.map(mail => mail.id)).size).toBe(names.length);
|
||||
expect(received.map(mail => mail.attachments[0].filename).sort()).toEqual([...names].sort());
|
||||
for (const mail of received) {
|
||||
expect(mail.attachments).toHaveLength(1);
|
||||
const attachment = mail.attachments[0];
|
||||
const response = await request.get(attachment.url);
|
||||
expect(response.status()).toBe(200);
|
||||
expect(await response.text()).toBe(attachment.filename);
|
||||
expect(new URL(attachment.url).pathname.split('/')[3]).toBe(String(mail.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await request.delete(`${variant.url}/admin/delete_address/${address_id}`);
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import { WORKER_URL, createTestAddress, seedTestMail } from '../../fixtures/test-helpers';
|
||||
|
||||
test('Webhook tests support random and specified mail IDs with ownership checks', async ({ request }) => {
|
||||
const mailbox = await createTestAddress(request, 'webhookid');
|
||||
const other = await createTestAddress(request, 'webhookother');
|
||||
const headers = { Authorization: `Bearer ${mailbox.jwt}` };
|
||||
const payloads: any[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', chunk => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
payloads.push(JSON.parse(Buffer.concat(chunks).toString()));
|
||||
res.writeHead(200).end();
|
||||
} catch {
|
||||
res.writeHead(400).end();
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '0.0.0.0', resolve));
|
||||
const port = (server.address() as import('node:net').AddressInfo).port;
|
||||
const settings = {
|
||||
enabled: true, url: `http://${process.env.CI ? 'e2e-runner' : 'localhost'}:${port}`,
|
||||
method: 'POST', headers: '{"Content-Type":"application/json"}',
|
||||
body: '{"id":"${id}","subject":"${subject}"}',
|
||||
};
|
||||
try {
|
||||
await seedTestMail(request, mailbox.address, { subject: 'First selected email' });
|
||||
await seedTestMail(request, mailbox.address, { subject: 'Second selected email' });
|
||||
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { headers });
|
||||
expect(list.ok()).toBe(true);
|
||||
const { results } = await list.json();
|
||||
expect(results).toHaveLength(2);
|
||||
const selected = results[1];
|
||||
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
|
||||
const count = payloads.length;
|
||||
expect((await request.post(`${WORKER_URL}${endpoint}`, { headers, data: settings })).ok()).toBe(true);
|
||||
await expect.poll(() => payloads.length).toBe(count + 1);
|
||||
if (endpoint.startsWith('/api/')) {
|
||||
expect(results.map((mail: any) => String(mail.id))).toContain(payloads[count].id);
|
||||
}
|
||||
expect((await request.post(`${WORKER_URL}${endpoint}`, {
|
||||
headers, data: { ...settings, mail_id: Number(selected.id) },
|
||||
})).ok()).toBe(true);
|
||||
await expect.poll(() => payloads.length).toBe(count + 2);
|
||||
expect(payloads[count + 1].id).toBe(String(selected.id));
|
||||
for (const mail_id of [0, -1, 1.5, '1', null]) {
|
||||
expect((await request.post(`${WORKER_URL}${endpoint}`, {
|
||||
headers, data: { ...settings, mail_id },
|
||||
})).status()).toBe(400);
|
||||
}
|
||||
expect((await request.post(`${WORKER_URL}${endpoint}`, {
|
||||
headers, data: { ...settings, mail_id: Number.MAX_SAFE_INTEGER },
|
||||
})).status()).toBe(404);
|
||||
expect(payloads).toHaveLength(count + 2);
|
||||
for (const [lang, invalid, missing] of [
|
||||
['zh', '无效的邮件 ID', '邮件不存在'],
|
||||
['en', 'Invalid mail ID', 'Mail not found'],
|
||||
]) {
|
||||
for (const body of ['null', '[]', '1', 'true', '"text"', '{', '']) {
|
||||
const response = await request.post(`${WORKER_URL}${endpoint}`, {
|
||||
headers: { ...headers, 'x-lang': lang, 'Content-Type': 'application/json' },
|
||||
data: body,
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
expect(await response.text()).toBe(lang === 'zh' ? '无效的请求体' : 'Invalid request body');
|
||||
}
|
||||
for (const [mail_id, status, message] of [[0, 400, invalid], [999999999, 404, missing]] as const) {
|
||||
const response = await request.post(`${WORKER_URL}${endpoint}`, {
|
||||
headers: { ...headers, 'x-lang': lang }, data: { ...settings, mail_id },
|
||||
});
|
||||
expect(response.status()).toBe(status);
|
||||
expect(await response.text()).toBe(message);
|
||||
}
|
||||
}
|
||||
expect(payloads).toHaveLength(count + 2);
|
||||
}
|
||||
const count = payloads.length;
|
||||
expect((await request.post(`${WORKER_URL}/api/webhook/test`, {
|
||||
headers: { Authorization: `Bearer ${other.jwt}` },
|
||||
data: { ...settings, mail_id: Number(selected.id) },
|
||||
})).status()).toBe(404);
|
||||
expect(payloads).toHaveLength(count);
|
||||
} finally {
|
||||
await request.delete(`${WORKER_URL}/admin/delete_address/${mailbox.address_id}`);
|
||||
await request.delete(`${WORKER_URL}/admin/delete_address/${other.address_id}`);
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -126,6 +126,96 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('signed attachment paths serve each attachment and reject tampering', async ({ request }) => {
|
||||
const { server, firstRequest, url } = await startWebhookReceiver();
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: true,
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: '{"attachments":${attachments}}',
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const attachment = Buffer.from('89504e470d0a1a0a', 'hex');
|
||||
const boundary = `webhook-attachment-${Date.now()}`;
|
||||
const raw = [
|
||||
`From: attachment-sender@test.example.com`,
|
||||
`To: ${address}`,
|
||||
`Subject: Webhook Attachment ${Date.now()}`,
|
||||
`Message-ID: <webhook-attachment-${Date.now()}@test>`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Attachment test`,
|
||||
`--${boundary}`,
|
||||
`Content-Type: image/png`,
|
||||
`Content-Disposition: attachment; filename="test.png"`,
|
||||
`Content-Transfer-Encoding: base64`,
|
||||
``,
|
||||
attachment.toString('base64'),
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain`,
|
||||
`Content-Disposition: attachment; filename="second.txt"`,
|
||||
`Content-Transfer-Encoding: base64`,
|
||||
``,
|
||||
Buffer.from('Second attachment').toString('base64'),
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||
data: {
|
||||
from: 'attachment-sender@test.example.com',
|
||||
to: address,
|
||||
raw,
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const payload = JSON.parse((await firstRequest).body);
|
||||
expect(payload.attachments).toHaveLength(2);
|
||||
expect(payload.attachments[0]).toMatchObject({ filename: 'test.png', mimeType: 'image/png' });
|
||||
expect(new URL(payload.attachments[0].url).origin).toBe(new URL(WORKER_URL).origin);
|
||||
const attachmentPath = new URL(payload.attachments[0].url).pathname;
|
||||
expect(attachmentPath).toMatch(/^\/open_api\/a\/\d+\/0\/\d+\/[a-z2-7]{51}[aq]$/);
|
||||
expect(payload.attachments[1].filename).toBe('second.txt');
|
||||
|
||||
const attachmentRes = await request.get(payload.attachments[0].url);
|
||||
expect(attachmentRes.ok()).toBe(true);
|
||||
expect(attachmentRes.headers()['content-type']).toBe('image/png');
|
||||
expect(attachmentRes.headers()['content-disposition']).toBe('inline');
|
||||
expect(attachmentRes.headers()['x-content-type-options']).toBe('nosniff');
|
||||
expect(attachmentRes.headers()['cache-control']).toBe('no-store');
|
||||
expect(Buffer.from(await attachmentRes.body())).toEqual(attachment);
|
||||
|
||||
const secondRes = await request.get(payload.attachments[1].url);
|
||||
expect(secondRes.status()).toBe(200);
|
||||
expect(secondRes.headers()['content-disposition']).toBe("attachment; filename*=UTF-8''second.txt");
|
||||
expect(secondRes.headers()['content-type']).toBe('application/octet-stream');
|
||||
expect(await secondRes.text()).toBe('Second attachment');
|
||||
|
||||
const parts = attachmentPath.split('/');
|
||||
parts[parts.length - 1] = (parts.at(-1)[0] === 'a' ? 'b' : 'a') + parts.at(-1).slice(1);
|
||||
const tamperedPath = parts.join('/');
|
||||
const tamperedRes = await request.get(`${WORKER_URL}${tamperedPath}`);
|
||||
expect(tamperedRes.status()).toBe(404);
|
||||
for (const index of ['1', '999', '-1']) {
|
||||
const changedIndex = attachmentPath.replace('/0/', `/${index}/`);
|
||||
expect((await request.get(`${WORKER_URL}${changedIndex}`)).status()).toBe(404);
|
||||
}
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('webhook is NOT called when disabled', async ({ request }) => {
|
||||
const { server, firstRequest, url } = await startWebhookReceiver();
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
for (const [locale, mails, webhook, testLabel, specified, mailId, invalid] of [
|
||||
['zh', '邮件', '邮件 Webhook', '测试', '指定 ID', '邮件 ID', '请输入有效的正整数邮件 ID'],
|
||||
['en', 'Emails', 'Mail Webhook', 'Test', 'Specify ID', 'Email ID', 'Enter a valid positive integer email ID'],
|
||||
['es', 'Correos', 'Webhook de correo', 'Prueba', 'Especificar ID', 'ID del correo', 'Introduce un ID de correo válido que sea un entero positivo'],
|
||||
['pt-BR', 'E-mails', 'Webhook de e-mail', 'Teste', 'Especificar ID', 'ID do e-mail', 'Digite um ID de e-mail válido que seja um número inteiro positivo'],
|
||||
['ja', 'メール', 'メールWebhook', 'テスト', 'ID を指定', 'メール ID', '有効な正の整数のメール ID を入力してください'],
|
||||
['de', 'E-Mails', 'Mail-Webhook', 'Test', 'ID angeben', 'E-Mail-ID', 'Gib eine gültige positive ganze Zahl als E-Mail-ID ein'],
|
||||
]) {
|
||||
test(`Webhook test dialog translations: ${locale}`, async ({ page }) => {
|
||||
await page.route('**/admin/mail_webhook/settings', route => route.fulfill({
|
||||
json: { enabled: true, url: 'https://example.com/webhook', method: 'POST', headers: '{}', body: '{}' },
|
||||
}));
|
||||
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
|
||||
await page.getByText(mails, { exact: true }).click();
|
||||
await page.getByText(webhook, { exact: true }).click();
|
||||
await page.getByRole('button', { name: testLabel, exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByText(specified, { exact: true }).click();
|
||||
await expect(dialog.getByPlaceholder(mailId, { exact: true })).toBeVisible();
|
||||
await dialog.getByRole('button', { name: testLabel, exact: true }).click();
|
||||
await expect(page.getByText(invalid, { exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('Webhook test dialog selects random or specified email', async ({ page }) => {
|
||||
const requests: any[] = [];
|
||||
let fail = false;
|
||||
await page.route('**/admin/mail_webhook/settings', route => route.fulfill({
|
||||
json: { enabled: true, url: 'https://example.com/webhook', method: 'POST', headers: '{}', body: '{}' },
|
||||
}));
|
||||
await page.route('**/admin/mail_webhook/test', route => {
|
||||
requests.push(route.request().postDataJSON());
|
||||
return route.fulfill({ status: fail ? 404 : 200, body: fail ? 'Mail not found' : '{"success":true}' });
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/zh/admin`);
|
||||
await page.getByText('邮件', { exact: true }).click();
|
||||
await page.getByText('邮件 Webhook', { exact: true }).click();
|
||||
const open = page.locator('#app').getByRole('button', { name: '测试', exact: true });
|
||||
await open.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText('随机邮件', { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByPlaceholder('邮件 ID', { exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
expect(requests).toHaveLength(0);
|
||||
await open.click();
|
||||
await dialog.getByRole('button', { name: '测试', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).not.toHaveProperty('mail_id');
|
||||
await open.click();
|
||||
await dialog.getByText('指定 ID', { exact: true }).click();
|
||||
await dialog.getByRole('button', { name: '测试', exact: true }).click();
|
||||
await expect(page.getByText('请输入有效的正整数邮件 ID', { exact: true })).toBeVisible();
|
||||
expect(requests).toHaveLength(1);
|
||||
await dialog.getByPlaceholder('邮件 ID', { exact: true }).fill('123');
|
||||
fail = true;
|
||||
await dialog.getByRole('button', { name: '测试', exact: true }).click();
|
||||
await expect(page.getByText(/Mail not found/).first()).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(requests[1].mail_id).toBe(123);
|
||||
fail = false;
|
||||
await dialog.getByRole('button', { name: '测试', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
expect(requests[2].mail_id).toBe(123);
|
||||
await open.click();
|
||||
await dialog.getByText('随机邮件', { exact: true }).click();
|
||||
await dialog.getByRole('button', { name: '测试', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
expect(requests[3]).not.toHaveProperty('mail_id');
|
||||
});
|
||||
@@ -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
|
||||
['您的验证码是 123456,5分钟内有效', '123456'],
|
||||
['【某某】验证码:839201,请勿泄露', '839201'],
|
||||
['验证码123456,10分钟内有效', '123456'],
|
||||
['您的登录动态码为 4821', '4821'],
|
||||
['123456 是您的验证码,请勿转发', '123456'],
|
||||
['驗證碼:556677', '556677'],
|
||||
['【必胜客】116352(动态验证码),请在30分钟内填写', '116352'], // 2FHey
|
||||
['【APPLE】Apple ID代码为:724818。请勿与他人共享。', '724818'], // 2FHey
|
||||
['【某视频】654321短信登录验证码,5分钟内有效', '654321'],
|
||||
['校验码:123456,请勿告诉他人', '123456'],
|
||||
['您的动态口令为 918273', '918273'],
|
||||
['支付宝校验码 4096,付款金额 169.00 元', '4096'],
|
||||
// Japanese / Korean
|
||||
['認証コードは 482913 です。', '482913'],
|
||||
['ワンタイムパスコード「736251」を入力してください', '736251'],
|
||||
['인증번호 [736251]를 입력해 주세요.', '736251'],
|
||||
['인증 코드: 918273', '918273'],
|
||||
// Other languages
|
||||
['Ваш код: 123456', '123456'],
|
||||
['123-456 — код для входа', '123456'],
|
||||
['Su código de verificación es 482913', '482913'],
|
||||
['PayPal : 551234 est votre code de sécurité', '551234'],
|
||||
["Code d'authentification : AAAA1A", 'AAAA1A'],
|
||||
['Ihr Bestätigungscode ist: AB3C45', 'AB3C45'], // 2FHey
|
||||
['קוד האימות שלך הוא 123456', '123456'], // 2FHey
|
||||
// English
|
||||
['Your verification code is: 482913', '482913'],
|
||||
['123456 is your Instagram code', '123456'],
|
||||
['G-482913 is your Google verification code', '482913'],
|
||||
['ABC123 is your verification code', 'ABC123'], // 2FHey
|
||||
['Your code: 123-456', '123456'],
|
||||
['Your one-time passcode is 12 34 56', '123456'],
|
||||
['Your confirmation code is 8 4 9 2 0 1. Enter this to finish signing up.', '849201'],
|
||||
['Your security verification code is: 749183. Use it to complete sign-in.', '749183'],
|
||||
['Here is your one-time verification passcode: K9X-4B2. Valid for 10 minutes.', 'K9X4B2'],
|
||||
["Code is: RKJ-YP6 We'll NEVER call or text for this code.", 'RKJYP6'], // 2FHey
|
||||
['Your code is\n\n 839201\n\nIt expires in 10 minutes', '839201'],
|
||||
['Enter this code to sign in\n 591 204\n', '591204'],
|
||||
['Please enter the code below:\nAB12CD\n', 'AB12CD'],
|
||||
['Order #20391 shipped. Call 400-820-1234. Your login code: 7F3K9Q', '7F3K9Q'],
|
||||
['Use 4821 to verify your account', '4821'],
|
||||
['Please use SGD-123456 within 3 minutes to authorize this transaction.', '123456'], // 2FHey
|
||||
['Please verify your email.\n\n706215\n\nThanks', '706215'],
|
||||
['Please confirm your email address\n\n706215\n', '706215'],
|
||||
['Email verification\n\n582013\n\nThis code expires soon.', '582013'],
|
||||
['请验证您的邮箱\n\n662817\n', '662817'],
|
||||
['706215\n\nPlease verify your email address to continue.', '706215'],
|
||||
['[ 706215 ]\nPlease confirm your account', '706215'],
|
||||
['Hello,\n\nUse the following code to log in:\n\n 274019\n\nIf you did not request this, ignore.', '274019'],
|
||||
['Your Microsoft account security code\nSecurity code: 3721\nAccount: a***@x.com', '3721'],
|
||||
['Here is your GitHub launch code: 12345678\n\n© 2026 GitHub, Inc. San Francisco, CA 94107', '12345678'],
|
||||
['Your PIN is 4821', '4821'],
|
||||
['Your OTP for payment of Rs 5000 is 482913', '482913'], // 2FHey
|
||||
['OTP for txn of Rs 5000.00 is 482913', '482913'],
|
||||
['Your verification code for order 99887766 is 123456', '123456'],
|
||||
['This output contains a captcha with non-alphanumeric characters: ABCD123', 'ABCD123'], // 2FHey
|
||||
['Login code: 12345. Do not give this code to anyone', '12345'],
|
||||
['Your code for mark.kennedy.5561@example.com is 902113', '902113'],
|
||||
['123456 is OTP for your fund transfer, valid for 5 mins.', '123456'],
|
||||
['123456 ist dein Amazon-Einmalkennwort. Teile es nicht mit anderen Personen.', '123456'],
|
||||
['222222 ist der Google Pay Aktivierungscode für deine Karte.', '222222'],
|
||||
['Il tuo codice di sicurezza è: 123456', '123456'],
|
||||
['Le code à saisir pour votre achat de 200,00 EUR est 12345678.', '12345678'],
|
||||
['[Binance TR] Doğrulama Kodu: 123456. Lütfen paylaşmayın', '123456'],
|
||||
['【銀行轉帳】OTP密碼1234567,密碼勿告知他人', '1234567'],
|
||||
];
|
||||
|
||||
const noCodeCases = [
|
||||
'Hi, invoice total 3500 yuan, see attachment',
|
||||
'Your order #582910 has shipped',
|
||||
'Meeting moved to 2026-09-18 15:30, room 1203',
|
||||
'Use promo code: SAVE2026 at checkout',
|
||||
'Sign in to see your order, total $1299.00',
|
||||
'verification code is: Your account',
|
||||
'Your verification code is 2026',
|
||||
'Security notice for account created on 20260411',
|
||||
'Please verify your email by clicking the link below.\n© 2026 GitHub, Inc. San Francisco, CA 94107',
|
||||
'Confirm your subscription. Reply STOP to 10086',
|
||||
'Sign in attempt from 192.168.1.1 at 12:30. Ref 88213',
|
||||
'登录提醒:您的账号于 2026年9月17日 在北京登录,如非本人操作请致电 95588',
|
||||
'code: 4155552671', // 2FHey
|
||||
'Your verification code is 1234.56',
|
||||
'Your verification code is EXPIRED',
|
||||
'Do not share this code. Code: NEVER',
|
||||
'Tracking code: 58291034',
|
||||
'Order code: 582910',
|
||||
'Reference code: 48291',
|
||||
'Voucher code: 123456',
|
||||
'Sign in to your account\n\n90210\n',
|
||||
'Please verify. Account ID:\n884211\n',
|
||||
'Order confirmation\n123456',
|
||||
'706215\n\nThanks for your order',
|
||||
'Please verify your email. Account ID:\n\n884211\n',
|
||||
'Order confirmation\n\nOrder number\n582910\n',
|
||||
'Your payment is confirmed.\n\n482910\n',
|
||||
'Booking confirmed\n\n77120\n\nSee you soon',
|
||||
'Your purchase was verified\n\n558812\n',
|
||||
'Transaction authorized\n\n904411\n',
|
||||
'Ihre Bestellbestätigung\n\n448812\n',
|
||||
'Подтверждение заказа\n\n551203\n',
|
||||
'订单已确认\n\n772019\n',
|
||||
'Verify your email: https://example.com/verify?token=123456&id=998877',
|
||||
'Your footprint report for 2026: 58291 steps',
|
||||
'Welcome to our service. Enjoy a 30% discount with code SUMMER2026!',
|
||||
'Your order 5000 is the total, see the code of conduct',
|
||||
'We sent a code to your phone number ending 5678',
|
||||
'Your kod verification: see attached, amount 1,234 PLN',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const [text, expected] of codeCases) {
|
||||
test(`extracts ${expected} from ${JSON.stringify(text)}`, () => {
|
||||
assert.equal(extractCode(text), expected);
|
||||
});
|
||||
}
|
||||
|
||||
for (const text of noCodeCases) {
|
||||
test(`extracts nothing from ${JSON.stringify(text)}`, () => {
|
||||
assert.equal(extractCode(text), null);
|
||||
});
|
||||
}
|
||||
|
||||
test('subject and body are combined like the Worker does', () => {
|
||||
assert.equal(extractCode(joinSubjectAndBody('482913 is your Acme verification code', 'Hi, thanks for signing up.')), '482913');
|
||||
assert.equal(extractCode(joinSubjectAndBody(undefined, 'Your verification code: 551203')), '551203');
|
||||
assert.equal(extractCode(joinSubjectAndBody('Welcome', '')), null);
|
||||
});
|
||||
|
||||
test('a very long subject cannot push the body code out of the analyzed text', () => {
|
||||
const text = joinSubjectAndBody('x'.repeat(30000), 'Your verification code: 123456');
|
||||
assert.equal(extractCode(text), '123456');
|
||||
});
|
||||
|
||||
// Guards against catastrophic regex backtracking on large or hostile mails.
|
||||
const hostileInputs = {
|
||||
spaces: ' '.repeat(200000) + 'x',
|
||||
newlines: '\n'.repeat(200000) + 'verify',
|
||||
keywordAndSpaces: ('code' + ' '.repeat(1000)).repeat(200),
|
||||
letters: 'a'.repeat(200000) + ' code',
|
||||
digits: '1'.repeat(200000),
|
||||
atSigns: 'a'.repeat(100000) + '@' + 'b.'.repeat(50000),
|
||||
hyphens: 'code: ' + 'abc-'.repeat(50000),
|
||||
cjk: '验证码的的的的的的'.repeat(20000),
|
||||
};
|
||||
|
||||
for (const [name, text] of Object.entries(hostileInputs)) {
|
||||
test(`stays fast on hostile input: ${name}`, () => {
|
||||
const start = performance.now();
|
||||
extractCode(text);
|
||||
assert.ok(performance.now() - start < 500, `took ${performance.now() - start}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
test('extract mode defaults to local when unset', () => {
|
||||
assert.equal(resolveExtractMode(undefined), ExtractMode.Local);
|
||||
assert.equal(resolveExtractMode(''), ExtractMode.Local);
|
||||
assert.equal(resolveExtractMode(' '), ExtractMode.Local);
|
||||
});
|
||||
|
||||
test('extract mode accepts explicit ai and local', () => {
|
||||
assert.equal(resolveExtractMode('ai'), ExtractMode.Ai);
|
||||
assert.equal(resolveExtractMode(' AI '), ExtractMode.Ai);
|
||||
assert.equal(resolveExtractMode('local'), ExtractMode.Local);
|
||||
});
|
||||
|
||||
test('extract mode rejects unknown values', () => {
|
||||
assert.equal(resolveExtractMode('auto'), null);
|
||||
assert.equal(resolveExtractMode('regex'), null);
|
||||
assert.equal(resolveExtractMode(true), null);
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.20.0",
|
||||
"dompurify": "^3.4.15",
|
||||
"jszip": "^3.10.1",
|
||||
"jszip": "^3.10.2",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.45.3",
|
||||
"postal-mime": "^2.7.6",
|
||||
@@ -53,7 +53,7 @@
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1",
|
||||
"wrangler": "^4.129.0"
|
||||
"wrangler": "^4.131.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+377
-370
File diff suppressed because it is too large
Load Diff
@@ -163,6 +163,10 @@ const handlePresetSelect = (key: number) => {
|
||||
|
||||
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
|
||||
const enableWebhook = ref(false)
|
||||
const showTestModal = ref(false)
|
||||
const testMode = ref('random')
|
||||
const testMailId = ref<number | null>(null)
|
||||
const testing = ref(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -188,15 +192,27 @@ const saveSettings = async () => {
|
||||
}
|
||||
|
||||
const testSettings = async () => {
|
||||
if (testing.value) return
|
||||
if (!webhookSettings.value.url) {
|
||||
message.error(t('urlMissing'))
|
||||
return
|
||||
}
|
||||
if (testMode.value === 'specified' && (!Number.isSafeInteger(testMailId.value) || (testMailId.value ?? 0) <= 0)) {
|
||||
message.error(t('invalidMailId'))
|
||||
return
|
||||
}
|
||||
testing.value = true
|
||||
try {
|
||||
await props.testSettings(webhookSettings.value)
|
||||
await props.testSettings({
|
||||
...webhookSettings.value,
|
||||
...(testMode.value === 'specified' ? { mail_id: testMailId.value } : {}),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
showTestModal.value = false
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +230,7 @@ onMounted(async () => {
|
||||
{{ t('presets') }}
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
|
||||
<n-button v-if="webhookSettings.enabled" @click="showTestModal = true" secondary>
|
||||
{{ t('test') }}
|
||||
</n-button>
|
||||
<n-button @click="saveSettings" type="primary">
|
||||
@@ -242,6 +258,27 @@ onMounted(async () => {
|
||||
</div>
|
||||
</n-card>
|
||||
<n-result v-else status="404" :title="t('notEnabled')" />
|
||||
<n-modal v-model:show="showTestModal" preset="card" :title="t('test')"
|
||||
style="width: min(420px, calc(100vw - 32px))" :mask-closable="!testing"
|
||||
:close-on-esc="!testing" :closable="!testing">
|
||||
<n-radio-group v-model:value="testMode" :disabled="testing">
|
||||
<n-space>
|
||||
<n-radio value="random">{{ t('randomMail') }}</n-radio>
|
||||
<n-radio value="specified">{{ t('specifiedMail') }}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<n-form-item v-if="testMode === 'specified'" :label="t('mailId')" style="margin-top: 16px">
|
||||
<n-input-number v-model:value="testMailId" :min="1" :max="Number.MAX_SAFE_INTEGER"
|
||||
:precision="0" :show-button="false" :disabled="testing" :placeholder="t('mailId')"
|
||||
style="width: 100%" />
|
||||
</n-form-item>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button :disabled="testing" @click="showTestModal = false">{{ t('cancel') }}</n-button>
|
||||
<n-button type="primary" :loading="testing" @click="testSettings">{{ t('test') }}</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const deMessages = {
|
||||
"components.WebhookComponent.randomMail": "Zufällige E-Mail",
|
||||
"components.WebhookComponent.specifiedMail": "ID angeben",
|
||||
"components.WebhookComponent.mailId": "E-Mail-ID",
|
||||
"components.WebhookComponent.invalidMailId": "Gib eine gültige positive ganze Zahl als E-Mail-ID ein",
|
||||
"components.WebhookComponent.cancel": "Abbrechen",
|
||||
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
|
||||
"views.index.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const esMessages = {
|
||||
"components.WebhookComponent.randomMail": "Correo aleatorio",
|
||||
"components.WebhookComponent.specifiedMail": "Especificar ID",
|
||||
"components.WebhookComponent.mailId": "ID del correo",
|
||||
"components.WebhookComponent.invalidMailId": "Introduce un ID de correo válido que sea un entero positivo",
|
||||
"components.WebhookComponent.cancel": "Cancelar",
|
||||
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
|
||||
"views.index.SendMail.composeMail": "Redactar correo",
|
||||
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const jaMessages = {
|
||||
"components.WebhookComponent.randomMail": "ランダムなメール",
|
||||
"components.WebhookComponent.specifiedMail": "ID を指定",
|
||||
"components.WebhookComponent.mailId": "メール ID",
|
||||
"components.WebhookComponent.invalidMailId": "有効な正の整数のメール ID を入力してください",
|
||||
"components.WebhookComponent.cancel": "キャンセル",
|
||||
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
|
||||
"views.index.SendMail.composeMail": "メールを作成",
|
||||
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const ptBRMessages = {
|
||||
"components.WebhookComponent.randomMail": "E-mail aleatório",
|
||||
"components.WebhookComponent.specifiedMail": "Especificar ID",
|
||||
"components.WebhookComponent.mailId": "ID do e-mail",
|
||||
"components.WebhookComponent.invalidMailId": "Digite um ID de e-mail válido que seja um número inteiro positivo",
|
||||
"components.WebhookComponent.cancel": "Cancelar",
|
||||
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
|
||||
"views.index.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export const MESSAGE_REGISTRY = {
|
||||
"components.WebhookComponent": {
|
||||
"randomMail": { "en": "Random email", "zh": "随机邮件" },
|
||||
"specifiedMail": { "en": "Specify ID", "zh": "指定 ID" },
|
||||
"mailId": { "en": "Email ID", "zh": "邮件 ID" },
|
||||
"invalidMailId": { "en": "Enter a valid positive integer email ID", "zh": "请输入有效的正整数邮件 ID" },
|
||||
"cancel": { "en": "Cancel", "zh": "取消" },
|
||||
"enable": {
|
||||
"en": "Enable",
|
||||
"zh": "启用"
|
||||
|
||||
@@ -19,7 +19,8 @@ export const getRouterPathWithLang = (path: string, lang: string) => {
|
||||
return getPathWithLocale(path, normalizedLang);
|
||||
}
|
||||
|
||||
export const utcToLocalDate = (utcDate: string, useUTCDate: boolean) => {
|
||||
export const utcToLocalDate = (utcDate: string | null | undefined, useUTCDate: boolean) => {
|
||||
if (!utcDate) return '';
|
||||
const utcDateString = `${utcDate} UTC`;
|
||||
if (useUTCDate) {
|
||||
return utcDateString;
|
||||
|
||||
@@ -5,14 +5,15 @@ import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { hashPassword } from '../../utils'
|
||||
import { hashPassword, utcToLocalDate } from '../../utils'
|
||||
import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||
|
||||
const {
|
||||
loading, adminTab, openSettings,
|
||||
adminMailTabAddress, adminSendBoxTabAddress
|
||||
adminMailTabAddress, adminSendBoxTabAddress,
|
||||
useUTCDate
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
@@ -274,13 +275,19 @@ const columns = computed(() => [
|
||||
title: t('created_at'),
|
||||
key: "created_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
|
||||
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return utcToLocalDate(row.created_at, useUTCDate.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
|
||||
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return utcToLocalDate(row.updated_at, useUTCDate.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('source_meta'),
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { utcToLocalDate } from '../../utils';
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const { loading, useUTCDate } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useScopedI18n('views.admin.SenderAccess')
|
||||
@@ -70,7 +71,10 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
key: "created_at",
|
||||
render(row) {
|
||||
return utcToLocalDate(row.created_at, useUTCDate.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('balance'),
|
||||
|
||||
@@ -6,11 +6,11 @@ import { MenuFilled } from '@vicons/material'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { hashPassword } from '../../utils';
|
||||
import { hashPassword, utcToLocalDate } from '../../utils';
|
||||
|
||||
import UserAddressManagement from './UserAddressManagement.vue'
|
||||
|
||||
const { loading, openSettings } = useGlobalState()
|
||||
const { loading, openSettings, useUTCDate } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useScopedI18n('views.admin.UserManagement')
|
||||
@@ -193,7 +193,10 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
key: "created_at",
|
||||
render(row) {
|
||||
return utcToLocalDate(row.created_at, useUTCDate.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.129.0"
|
||||
"wrangler": "^4.131.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
|
||||
## Features
|
||||
|
||||
The AI email recognition feature uses Cloudflare Workers AI to automatically analyze incoming email content and intelligently extract important information, including:
|
||||
The email recognition feature automatically analyzes incoming email content and extracts important information. It supports two mutually exclusive modes:
|
||||
|
||||
| Mode | Extracts | Privacy | Requires |
|
||||
| ---- | -------- | ------- | -------- |
|
||||
| `local` (default) | **Verification codes** (auth_code) only | Built-in rules run inside the Worker; mail content is **never sent to any AI model** | Nothing, zero cost |
|
||||
| `ai` | Verification codes, auth links, service links, subscription links, other links | Mail content is sent to the Workers AI model in your Cloudflare account | Workers AI binding |
|
||||
|
||||
Types recognized in `ai` mode:
|
||||
|
||||
- **Verification Code** (auth_code) - OTP, security code, confirmation code, etc.
|
||||
- **Authentication Link** (auth_link) - Login, verify, activate, password reset links
|
||||
@@ -15,26 +22,55 @@ The AI email recognition feature uses Cloudflare Workers AI to automatically ana
|
||||
- **Subscription Link** (subscription_link) - Unsubscribe, manage subscription links
|
||||
- **Other Link** (other_link) - Other valuable links
|
||||
|
||||
Extraction results are automatically saved to the `metadata` field in the database, and the frontend can directly display extracted verification codes or links.
|
||||
Extraction results are automatically saved to the `metadata` field in the database, the frontend can directly display extracted verification codes or links, and Telegram pushes and webhook placeholders reuse the same result.
|
||||
|
||||
## Configuration Variables
|
||||
|
||||
| Variable Name | Type | Description | Example |
|
||||
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable AI email recognition feature | `true` |
|
||||
| `AI_EXTRACT_MODEL` | Text | AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct-fast` |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable email recognition (master switch, required by both modes) | `true` |
|
||||
| `AI_EXTRACT_MODE` | Text | Recognition mode: `local` uses built-in rules only, `ai` 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.
|
||||
|
||||
## Workers AI Binding
|
||||
### Workers AI Binding
|
||||
|
||||
Configure Workers AI binding in `wrangler.toml`:
|
||||
|
||||
```toml
|
||||
AI_EXTRACT_MODE = "ai"
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
```
|
||||
@@ -43,26 +79,14 @@ Or add in Cloudflare Dashboard Worker settings:
|
||||
- **Variable name**: `AI`
|
||||
- **Type**: Workers AI
|
||||
|
||||
## Fallback Without a Workers AI Binding
|
||||
|
||||
If `ENABLE_AI_EMAIL_EXTRACT` is enabled but **no Workers AI binding is configured** (e.g. a self-hosted deployment without Workers AI), the system automatically falls back to a built-in **regex verification-code extractor**:
|
||||
|
||||
- Extracts **verification codes** (`auth_code`) only; links are not extracted (link extraction requires AI)
|
||||
- Zero dependency, zero cost, runs locally inside the Worker
|
||||
- Supports common verification-code formats in English, Chinese, Japanese and Korean
|
||||
- Rejects years (e.g. `2026`) and `YYYYMMDD` dates to reduce false positives
|
||||
- Results are written to `metadata` and reuse the same Telegram / webhook placeholders (`aiExtractType` is `auth_code` in this case)
|
||||
|
||||
When a Workers AI binding is configured, AI extraction is still preferred (recognizing both codes and links) and this fallback does not apply.
|
||||
|
||||
## Address Allowlist (Optional)
|
||||
|
||||
To control costs and resource usage, you can configure an address allowlist in the Admin console's **AI Extract Settings** page:
|
||||
To control costs and resource usage, you can configure an address allowlist in the Admin console's **AI Extract Settings** page. 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
|
||||
|
||||
- **Allowlist Disabled**: AI extraction will process all email addresses
|
||||
- **Allowlist Enabled**: AI extraction will only process addresses in the allowlist
|
||||
- **Allowlist Disabled**: Workers AI extraction can process all email addresses
|
||||
- **Allowlist Enabled**: Workers AI is called only for addresses in the allowlist; addresses outside it skip Workers AI and fall back to local verification-code extraction
|
||||
|
||||
### Allowlist Format
|
||||
|
||||
@@ -82,7 +106,7 @@ user@example.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)
|
||||
- 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`)
|
||||
|
||||
@@ -99,6 +99,21 @@ Push email notifications by calling the Telegram Bot API directly via webhook. S
|
||||
|
||||
## Webhook Data Format
|
||||
|
||||
Insert attachment links directly into the final Body text:
|
||||
|
||||
- `${attachmentLinks}`: Plain URLs for all attachments, one per line, without file-type filtering.
|
||||
- `${attachmentMarkdownLinks}`: Markdown links `[filename](URL)` for all attachments, one per line, without file-type filtering.
|
||||
|
||||
For example, `{"content":"Attachments:\n${attachmentMarkdownLinks}"}`. Expanded lists are empty without attachments or a backend URL. Link rendering is determined by the receiving platform. The Webhook test buttons also support these variables using the selected test email’s attachments.
|
||||
|
||||
`${attachments}` provides a JSON array of all attachments, each with `filename`, `mimeType`, and `url`. Insert this placeholder directly as a JSON value, **without quotes**:
|
||||
|
||||
```json
|
||||
{"attachments": ${attachments}}
|
||||
```
|
||||
|
||||
Example output: `{"attachments":[{"filename":"a.png","mimeType":"image/png","url":"https://temp-email-api.example.com/open_api/a/123/0/..."}]}`. Emails without attachments produce `[]`. Attachment URLs use BACKEND_URL and can be used directly. Attachment indices are included in the signature, so modifying an index cannot grant access to another attachment. Links are temporary access credentials: use HTTPS and avoid sharing them publicly.
|
||||
|
||||
To get the url, you need to configure the worker's `FRONTEND_URL` to your frontend address, or you can construct the url yourself using `id` = `${FRONTEND_URL}?mail_id=${id}`
|
||||
|
||||
```json
|
||||
@@ -111,6 +126,7 @@ To get the url, you need to configure the worker's `FRONTEND_URL` to your fronte
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
"attachments": ${attachments},
|
||||
"aiExtractType": "${aiExtractType}",
|
||||
"aiExtractResult": "${aiExtractResult}",
|
||||
"aiExtractResultText": "${aiExtractResultText}",
|
||||
@@ -118,3 +134,11 @@ To get the url, you need to configure the worker's `FRONTEND_URL` to your fronte
|
||||
```
|
||||
|
||||
When AI email extraction is enabled, webhook templates can use the `aiExtractType`, `aiExtractResult`, and `aiExtractResultText` placeholders. They are empty strings when no extraction result is available.
|
||||
|
||||
Click **Test** to choose a random email (default) or specify an email ID. Missing specified emails return an error without falling back to a random email. Mailbox tests can only use that mailbox's emails; administrators can select any email. The existing `/api/webhook/test` and `/admin/mail_webhook/test` endpoints accept an optional positive integer `mail_id` in the request body. Omitting it preserves random selection. The UI sends this field only for testing, without saving it in the Webhook configuration.
|
||||
|
||||
Each `url` directly accesses the backend attachment endpoint. It is signed with `JWT_SECRET`, and expires after 24 hours. Signatures use 52-character lowercase Base32 with case-insensitive signature verification and no HMAC-SHA256 truncation. Files other than PNG, JPEG, GIF, or WebP images are served as downloads. The endpoint cannot retrieve attachments after the email is deleted or when configuration removed them before storage.
|
||||
|
||||
Set `BACKEND_URL = "https://temp-email-api.example.com"` in the Worker to its public base URL (a trailing slash is supported). No frontend proxy is required. Attachment URLs are empty when unset; mail-page links continue to use `FRONTEND_URL`.
|
||||
|
||||
When `ENABLE_WEBHOOK` is disabled, attachment downloads return 403 even if the signature has not expired. Requests do not reach the email database lookup or parsing steps. Attachment downloads do not require KV; saving and reading Webhook configuration still requires KV.
|
||||
|
||||
@@ -128,6 +128,7 @@ When `ADMIN_API_IP_WHITELIST` is unset or empty, source IPs are not restricted.
|
||||
| ---------------- | --------- | ------------------------------------------------- | ------------------ |
|
||||
| `ENABLE_WEBHOOK` | Text/JSON | Whether to enable webhook | `true` |
|
||||
| `FRONTEND_URL` | Text | Frontend URL, used for sending webhook email URLs | `https://xxxx.xxx` |
|
||||
| `BACKEND_URL` | Text | Public backend base URL for signed attachment links; attachment URLs are empty when unset | `https://temp-email-api.example.com` |
|
||||
|
||||
> [!NOTE]
|
||||
> Webhook functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
|
||||
## 功能说明
|
||||
|
||||
AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内容,智能提取重要信息,包括:
|
||||
邮件识别功能会自动分析收到的邮件内容,提取其中的重要信息,并支持两种互斥的识别模式:
|
||||
|
||||
| 模式 | 识别内容 | 隐私 | 依赖 |
|
||||
| ---- | -------- | ---- | ---- |
|
||||
| `local`(默认) | 仅**验证码** (auth_code) | 在 Worker 内用内置规则识别,邮件内容**不会发送给任何 AI 模型** | 无,零成本 |
|
||||
| `ai` | 验证码、认证链接、服务链接、订阅管理链接、其他链接 | 邮件内容会发送给你 Cloudflare 账号下的 Workers AI 模型 | Workers AI 绑定 |
|
||||
|
||||
`ai` 模式可识别的类型:
|
||||
|
||||
- **验证码** (auth_code) - OTP、安全码、确认码等
|
||||
- **认证链接** (auth_link) - 登录、验证、激活、重置密码链接
|
||||
@@ -15,26 +22,55 @@ AI 邮件识别功能使用 Cloudflare Workers AI 自动分析收到的邮件内
|
||||
- **订阅管理链接** (subscription_link) - 退订、管理订阅等链接
|
||||
- **其他链接** (other_link) - 其他有价值的链接
|
||||
|
||||
提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接。
|
||||
提取结果会自动保存到数据库的 `metadata` 字段中,前端可以直接展示提取的验证码或链接,Telegram 推送与 Webhook 占位符也会复用该结果。
|
||||
|
||||
## 配置变量
|
||||
|
||||
| 变量名 | 类型 | 说明 | 示例 |
|
||||
| ------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------- |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用 AI 邮件识别功能 | `true` |
|
||||
| `AI_EXTRACT_MODEL` | 文本 | AI 模型名称,从[支持 JSON 模式的模型](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models)中选择 | `@cf/meta/llama-3.1-8b-instruct-fast` |
|
||||
| `ENABLE_AI_EMAIL_EXTRACT` | 文本/JSON | 是否启用邮件识别功能(总开关,两种模式都需要) | `true` |
|
||||
| `AI_EXTRACT_MODE` | 文本 | 识别模式:`local` 仅用内置规则,`ai` 优先使用 Workers AI。不填默认为 `local`,填写其他值会记录错误日志并跳过识别 | `local` |
|
||||
| `AI_EXTRACT_MODEL` | 文本 | 仅 `ai` 模式生效。AI 模型名称,从[支持 JSON 模式的模型](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models)中选择 | `@cf/meta/llama-3.1-8b-instruct-fast` |
|
||||
|
||||
推荐使用 `@cf/meta/llama-3.1-8b-instruct-fast` 作为默认模型,它支持当前实现依赖的 JSON Mode,且 Cloudflare 说明 `-fast` 变体会保持可用。价格更低的 `@cf/meta/llama-3.1-8b-instruct-fp8-fast` 目前不在 JSON Mode 支持列表中,不建议用于本功能。Cloudflare 推荐的新模型 `@cf/zai-org/glm-4.7-flash` 适合多语言场景,但用于本功能前请先确认它在你的账号/区域支持结构化 JSON 输出。旧默认模型 `@cf/meta/llama-3.1-8b-instruct` 将于 2026-05-30 被 Cloudflare 弃用,不建议继续使用。
|
||||
> [!WARNING] 从旧版本升级
|
||||
> 旧版本在配置了 Workers AI 绑定时会自动使用 AI 识别。现在不填 `AI_EXTRACT_MODE` 时默认使用本地规则,如需继续使用 AI 识别,请显式设置 `AI_EXTRACT_MODE = "ai"`。
|
||||
|
||||
## 内容长度限制
|
||||
两种模式的主要行为:
|
||||
|
||||
- `local` 模式即使配置了 Workers AI 绑定,也不会调用 AI
|
||||
- `ai` 模式未配置 Workers AI 绑定或调用模型失败时,会记录错误日志并跳过本封邮件的识别,不会改用本地规则
|
||||
- `ai` 模式下如果地址未命中 AI 提取白名单,只会跳过 Workers AI 调用,仍会改用本地规则尝试提取验证码
|
||||
|
||||
## 本地规则模式(local)
|
||||
|
||||
- 仅提取**验证码**(`auth_code`),不提取链接
|
||||
- 零依赖、零成本,在 Worker 内本地完成,邮件内容不会离开 Worker
|
||||
- 同时识别**邮件标题**和正文,标题中的验证码(如 `123456 is your verification code`)也能提取
|
||||
- 支持中文、英文、日文、韩文,以及俄语、西班牙语、葡萄牙语、法语、德语、意大利语、土耳其语、希伯来语的常见写法,例如:
|
||||
- 关键词在前:`验证码:123456`、`Apple ID代码为:724818`、`認証コードは 123456 です`、`인증번호 [123456]`、`Ваш код: 123456`
|
||||
- 验证码在前:`123456 是您的验证码`、`116352(动态验证码)`、`G-123456 is your Google verification code`、`123456 est votre code de sécurité`
|
||||
- 关键词与验证码之间有其他词:`Your OTP for payment of Rs 5000 is 482913`
|
||||
- 支持带分隔符、空格、零宽字符或全角数字的验证码(如 `123-456`、`8 4 9 2 0 1`、`K9X-4B2`、`123456`),结果会去掉分隔符和 `G-` 这类字母前缀
|
||||
- 字母数字混合的验证码必须包含数字,纯字母验证码(如 `QGFDAE`)不会识别,以免把 `EXPIRED` 这类单词误判为验证码
|
||||
- 自动排除:年份与 `YYYYMMDD` 日期、超过 8 位的数字(如电话号码)、小数与金额、时间、URL 和邮箱地址中的数字,以及 promo / tracking / order / reference / voucher code 等非验证码
|
||||
- 没有明确关键词时,只有在验证类邮件中、且数字**单独成行**或紧跟「输入 / use / enter」时才会识别,避免把订单号、客服电话、邮编误判为验证码
|
||||
- 标题最多取前 1000 个字符,与正文合并后只分析前 20000 个字符,保证大邮件的 CPU 耗时可控
|
||||
|
||||
## AI 模式(ai)
|
||||
|
||||
推荐使用 `@cf/meta/llama-3.1-8b-instruct-fast` 作为默认模型,它支持当前实现依赖的 JSON Mode,且 Cloudflare 说明 `-fast` 变体会保持可用。价格更低的 `@cf/meta/llama-3.1-8b-instruct-fp8-fast` 目前不在 JSON Mode 支持列表中,不建议用于本功能。Cloudflare 推荐的新模型 `@cf/zai-org/glm-4.7-flash` 适合多语言场景,但用于本功能前请先确认它在你的账号/区域支持结构化 JSON 输出。旧默认模型 `@cf/meta/llama-3.1-8b-instruct` 已于 2026-05-30 被 Cloudflare 弃用,不建议继续使用。
|
||||
|
||||
### 内容长度限制
|
||||
|
||||
为避免 AI 模型 token 限制,邮件内容最大处理长度为 **4000 字符**。超过此长度的邮件内容将被截断后再进行 AI 分析。
|
||||
|
||||
## Workers AI 绑定
|
||||
### Workers AI 绑定
|
||||
|
||||
需要在 `wrangler.toml` 中配置 Workers AI 绑定:
|
||||
|
||||
```toml
|
||||
AI_EXTRACT_MODE = "ai"
|
||||
|
||||
[ai]
|
||||
binding = "AI"
|
||||
```
|
||||
@@ -43,26 +79,14 @@ binding = "AI"
|
||||
- **Variable name**: `AI`
|
||||
- **Type**: Workers AI
|
||||
|
||||
## 无 Workers AI 绑定时的正则兜底
|
||||
|
||||
如果启用了 `ENABLE_AI_EMAIL_EXTRACT` 但**没有配置 Workers AI 绑定**(例如自部署时未开通 Workers AI),系统会自动回退到内置的**正则验证码提取**:
|
||||
|
||||
- 仅提取**验证码**(`auth_code`),不提取链接(链接提取依赖 AI)
|
||||
- 零依赖、零成本,在 Worker 内本地完成
|
||||
- 支持中文、英文、日文、韩文常见验证码格式
|
||||
- 自动排除年份(如 `2026`)与 `YYYYMMDD` 日期,降低误判
|
||||
- 提取结果同样写入 `metadata`,并复用 Telegram 推送与 Webhook 占位符(此时 `aiExtractType` 为 `auth_code`)
|
||||
|
||||
当配置了 Workers AI 绑定时,仍优先使用 AI 提取(可识别验证码与各类链接),不受此回退影响。
|
||||
|
||||
## 地址白名单(可选)
|
||||
|
||||
为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单:
|
||||
为了控制成本和资源使用,可以在 Admin 控制台的 **AI 提取设置** 页面配置地址白名单。白名单只控制 Workers AI 调用,不限制本地规则模式;`ai` 模式下未命中白名单的地址仍会使用本地规则尝试提取验证码。
|
||||
|
||||
### 配置说明
|
||||
|
||||
- **未启用白名单**:所有邮箱地址都可使用 AI 提取功能
|
||||
- **启用白名单**:仅白名单中的邮箱地址会进行 AI 提取
|
||||
- **未启用白名单**:所有邮箱地址都可使用 Workers AI 提取
|
||||
- **启用白名单**:仅白名单中的邮箱地址会调用 Workers AI;未命中的地址会跳过 Workers AI,并回退到本地验证码提取
|
||||
|
||||
### 白名单格式
|
||||
|
||||
@@ -82,7 +106,7 @@ user@example.com
|
||||
admin*@company.com
|
||||
```
|
||||
|
||||
此配置将只对以下邮箱进行 AI 提取:
|
||||
此配置将只对以下邮箱调用 Workers AI:
|
||||
- `user@example.com`(精确匹配)
|
||||
- 所有 `@mydomain.com` 的邮箱(如 `test@mydomain.com`、`admin@mydomain.com`)
|
||||
- 所有 `admin` 开头的 `@company.com` 邮箱(如 `admin@company.com`、`admin123@company.com`)
|
||||
|
||||
@@ -99,6 +99,21 @@
|
||||
|
||||
## webhook 数据格式
|
||||
|
||||
Body 中可以将附件链接直接插入最终文本:
|
||||
|
||||
- `${attachmentLinks}`:所有附件的纯 URL,每行一个,不按文件类型过滤。
|
||||
- `${attachmentMarkdownLinks}`:所有附件的 Markdown 链接 `[文件名](URL)`,每行一个,不按文件类型过滤。
|
||||
|
||||
例如 `{"content":"附件:\n${attachmentMarkdownLinks}"}`。无附件或未配置后端地址时,展开的链接列表为空。链接如何展示由接收平台决定。页面上的 Webhook 测试按钮也支持这些变量,使用所选测试邮件的附件。
|
||||
|
||||
`${attachments}` 返回所有附件的 JSON 数组,每项包含 `filename`、`mimeType`、`url`。将此变量直接放在 JSON 值的位置,**不要加引号**:
|
||||
|
||||
```json
|
||||
{"attachments": ${attachments}}
|
||||
```
|
||||
|
||||
例如返回 `{"attachments":[{"filename":"a.png","mimeType":"image/png","url":"https://temp-email-api.example.com/open_api/a/123/0/..."}]}`。无附件时为 `[]`。附件链接使用 `BACKEND_URL`,接收端可直接使用每项 `url`;附件序号也参与签名,不能修改路径读取其他附件。链接本身是临时访问凭证,请使用 HTTPS 传输并避免公开分享。
|
||||
|
||||
要获取 url 需要配置 worker 的 `FRONTEND_URL` 为你的前端地址,或者你可以通过 `id` 自己拼接 url = `${FRONTEND_URL}?mail_id=${id}`
|
||||
|
||||
```json
|
||||
@@ -111,6 +126,7 @@
|
||||
"raw": "${raw}",
|
||||
"parsedText": "${parsedText}",
|
||||
"parsedHtml": "${parsedHtml}",
|
||||
"attachments": ${attachments},
|
||||
"aiExtractType": "${aiExtractType}",
|
||||
"aiExtractResult": "${aiExtractResult}",
|
||||
"aiExtractResultText": "${aiExtractResultText}",
|
||||
@@ -118,3 +134,11 @@
|
||||
```
|
||||
|
||||
启用 AI 邮件内容提取后,Webhook 模板可使用 `aiExtractType`、`aiExtractResult`、`aiExtractResultText` 占位符。未提取到结果时这些字段为空字符串。
|
||||
|
||||
点击“测试”会弹出选择框:默认随机选择邮件,也可以选择“指定 ID”并输入邮件 ID。指定邮件不存在时会报错,不会回退随机;邮箱页面只能使用当前邮箱的邮件,管理员页面可指定任意邮件。现有测试接口 `/api/webhook/test` 和 `/admin/mail_webhook/test` 的请求 Body 支持可选正整数 `mail_id`,不传则沿用随机逻辑。页面仅在测试请求中传入该参数,不会保存到 Webhook 配置。
|
||||
|
||||
每项 `url` 直接访问后端附件接口,使用 `JWT_SECRET` 签名并在 24 小时后失效。签名使用 52 字符的小写 Base32 编码,校验兼容签名部分的大小写,不截断 HMAC-SHA256。非 PNG、JPEG、GIF、WebP 的附件会作为文件下载。邮件被删除或附件在入库前被配置移除时,无法通过接口读取附件。
|
||||
|
||||
在 Worker 中配置 `BACKEND_URL = "https://temp-email-api.example.com"`,使用后端公网根地址(支持末尾斜杠),不需要前端代理。未配置时附件的 `url` 为空;邮件页面链接仍使用 `FRONTEND_URL`。
|
||||
|
||||
关闭 `ENABLE_WEBHOOK` 后,附件下载接口返回 403,即使链接签名尚未过期也无法下载;请求不会进入邮件数据库查询和解析流程。附件下载本身不依赖 KV,Webhook 配置的保存和读取仍需要 KV。
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
| ---------------- | --------- | ------------------------------------- | ------------------ |
|
||||
| `ENABLE_WEBHOOK` | 文本/JSON | 是否启用 webhook | `true` |
|
||||
| `FRONTEND_URL` | 文本 | 前端地址,用于发送 webhook 的邮件 url | `https://xxxx.xxx` |
|
||||
| `BACKEND_URL` | 文本 | 后端公网根地址,用于附件签名链接;未配置时附件 URL 为空 | `https://temp-email-api.example.com` |
|
||||
|
||||
> [!NOTE]
|
||||
> webhook 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/node": "^26.5.1",
|
||||
"vitepress": "^1.6.4",
|
||||
"wrangler": "^4.129.0"
|
||||
"wrangler": "^4.131.1"
|
||||
},
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
"jszip": "^3.10.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+420
-412
File diff suppressed because it is too large
Load Diff
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,13 +11,13 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260906.1",
|
||||
"@cloudflare/workers-types": "^5.20260911.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@types/node": "^25.9.5",
|
||||
"@types/node": "^25.9.6",
|
||||
"eslint": "9.39.1",
|
||||
"globals": "^16.5.0",
|
||||
"typescript-eslint": "^8.69.0",
|
||||
"wrangler": "^4.129.0"
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"wrangler": "^4.131.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
@@ -27,7 +27,7 @@
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"mimetext": "^3.0.28",
|
||||
"postal-mime": "^2.7.6",
|
||||
"resend": "^6.26.0",
|
||||
"resend": "^6.28.0",
|
||||
"telegraf": "4.16.3",
|
||||
"worker-mailer": "^1.2.1"
|
||||
},
|
||||
|
||||
Generated
+249
-241
@@ -35,8 +35,8 @@ importers:
|
||||
specifier: ^2.7.6
|
||||
version: 2.7.6
|
||||
resend:
|
||||
specifier: ^6.26.0
|
||||
version: 6.26.0(@react-email/render@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||
specifier: ^6.28.0
|
||||
version: 6.28.0(@react-email/render@1.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
|
||||
telegraf:
|
||||
specifier: 4.16.3
|
||||
version: 4.16.3(patch_hash=4ce8461c7f21d886ddd30d13d2be4fc8c72134fd7ffa5da3b27319c883447eed)
|
||||
@@ -45,14 +45,14 @@ importers:
|
||||
version: 1.2.1
|
||||
devDependencies:
|
||||
'@cloudflare/workers-types':
|
||||
specifier: ^5.20260906.1
|
||||
version: 5.20260906.1
|
||||
specifier: ^5.20260911.1
|
||||
version: 5.20260911.1
|
||||
'@eslint/js':
|
||||
specifier: 9.39.1
|
||||
version: 9.39.1
|
||||
'@types/node':
|
||||
specifier: ^25.9.5
|
||||
version: 25.9.5
|
||||
specifier: ^25.9.6
|
||||
version: 25.9.6
|
||||
eslint:
|
||||
specifier: 9.39.1
|
||||
version: 9.39.1
|
||||
@@ -60,11 +60,11 @@ importers:
|
||||
specifier: ^16.5.0
|
||||
version: 16.5.0
|
||||
typescript-eslint:
|
||||
specifier: ^8.69.0
|
||||
version: 8.69.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
specifier: ^8.70.0
|
||||
version: 8.70.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
wrangler:
|
||||
specifier: ^4.129.0
|
||||
version: 4.129.0(@cloudflare/workers-types@5.20260906.1)
|
||||
specifier: ^4.131.1
|
||||
version: 4.131.1(@cloudflare/workers-types@5.20260911.1)(@types/node@25.9.6)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -252,38 +252,38 @@ packages:
|
||||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260903.1':
|
||||
resolution: {integrity: sha512-FG+4mGxAXhKiL/1temH42alevIkumtYXNidhTa//3yULpzux6APw5UNVocI/vCQ1yG1YOEZRfbVy8lyuipM9MQ==}
|
||||
'@cloudflare/workerd-darwin-64@1.20260911.1':
|
||||
resolution: {integrity: sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260903.1':
|
||||
resolution: {integrity: sha512-o241VefnjG8eG+kGap5CjgV4zOT5UaAmS3OR1VZCpNj7vkXGxvp9KftKvtQgcCsIqJKaKx+7Xd7xq0L1DjkfPQ==}
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260911.1':
|
||||
resolution: {integrity: sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260903.1':
|
||||
resolution: {integrity: sha512-/VEvvtQ/XKf6HlBbg6FbvpwcpfYUcx6Fv6RkASY8DyEmUyuJ8rc7Qxil83ClRFoBzz/GY0BV94UZ6F+wers/hg==}
|
||||
'@cloudflare/workerd-linux-64@1.20260911.1':
|
||||
resolution: {integrity: sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260903.1':
|
||||
resolution: {integrity: sha512-OWhihGC6KoTXF4u2C1AonfpgXeM4/7p/1IXuALqXESmFUpLLP5gZhRzjSk/gWW+mrCZDfSrvnjifl+lRqselbA==}
|
||||
'@cloudflare/workerd-linux-arm64@1.20260911.1':
|
||||
resolution: {integrity: sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260903.1':
|
||||
resolution: {integrity: sha512-soPMF9/aMHlHKK7M0vq5HrRRicPbnZO1F6ZZ7JWN8EltxWJU5L7CEq7CxjO878DzyPx7gYvqBFy3SVgdZKZjMQ==}
|
||||
'@cloudflare/workerd-windows-64@1.20260911.1':
|
||||
resolution: {integrity: sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@cloudflare/workers-types@5.20260906.1':
|
||||
resolution: {integrity: sha512-mRKC5n+9OP7tZiOu7vMv2MZ+Arh3tX4b9gVAt43WFns83LNzWchhfRs9o2IkFCnYKovHo4m4Af1P4zPQWdLx0g==}
|
||||
'@cloudflare/workers-types@5.20260911.1':
|
||||
resolution: {integrity: sha512-yiAvknjulcU85B3yB4aKOn9+l+garWP+AbHgsdCFckeRYDdhZ1rPULi64BDf52R3VTaKqxi47kF+o9ZbjsCt5g==}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
@@ -513,144 +513,144 @@ packages:
|
||||
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==}
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.2':
|
||||
resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==}
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==}
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
os: [freebsd]
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==}
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.1':
|
||||
resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==}
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==}
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.1':
|
||||
resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==}
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.1':
|
||||
resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==}
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.1':
|
||||
resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==}
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.1':
|
||||
resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==}
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.1':
|
||||
resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==}
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
|
||||
resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==}
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
|
||||
resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==}
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==}
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-arm@0.35.2':
|
||||
resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==}
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.2':
|
||||
resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==}
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.2':
|
||||
resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==}
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.2':
|
||||
resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==}
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linux-x64@0.35.2':
|
||||
resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==}
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==}
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.2':
|
||||
resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==}
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@img/sharp-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==}
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.2':
|
||||
resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==}
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.2':
|
||||
resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==}
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.2':
|
||||
resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==}
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
|
||||
engines: {node: ^20.9.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@img/sharp-win32-x64@0.35.2':
|
||||
resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==}
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -945,69 +945,69 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/node@25.9.5':
|
||||
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
|
||||
'@types/node@25.9.6':
|
||||
resolution: {integrity: sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==}
|
||||
|
||||
'@types/uuid@9.0.8':
|
||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.69.0':
|
||||
resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==}
|
||||
'@typescript-eslint/eslint-plugin@8.70.0':
|
||||
resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.69.0
|
||||
'@typescript-eslint/parser': ^8.70.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/parser@8.69.0':
|
||||
resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==}
|
||||
'@typescript-eslint/parser@8.70.0':
|
||||
resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/project-service@8.69.0':
|
||||
resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==}
|
||||
'@typescript-eslint/project-service@8.70.0':
|
||||
resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/scope-manager@8.69.0':
|
||||
resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==}
|
||||
'@typescript-eslint/scope-manager@8.70.0':
|
||||
resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/tsconfig-utils@8.69.0':
|
||||
resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==}
|
||||
'@typescript-eslint/tsconfig-utils@8.70.0':
|
||||
resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/type-utils@8.69.0':
|
||||
resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==}
|
||||
'@typescript-eslint/type-utils@8.70.0':
|
||||
resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/types@8.69.0':
|
||||
resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==}
|
||||
'@typescript-eslint/types@8.70.0':
|
||||
resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/typescript-estree@8.69.0':
|
||||
resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==}
|
||||
'@typescript-eslint/typescript-estree@8.70.0':
|
||||
resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/utils@8.69.0':
|
||||
resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==}
|
||||
'@typescript-eslint/utils@8.70.0':
|
||||
resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.69.0':
|
||||
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
|
||||
'@typescript-eslint/visitor-keys@8.70.0':
|
||||
resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
@@ -1278,8 +1278,8 @@ packages:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
ignore@7.0.8:
|
||||
resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==}
|
||||
ignore@7.0.9:
|
||||
resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
@@ -1365,8 +1365,8 @@ packages:
|
||||
mimetext@3.0.28:
|
||||
resolution: {integrity: sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g==}
|
||||
|
||||
miniflare@5.20260903.0-alpha:
|
||||
resolution: {integrity: sha512-VCZIFxOqFXeibRBJWwTlppqbv2lkeO10IX3QBRGK9j1QiMAfH/OSsCvbjp1MslBMhVZmy2VTRlVaVnsKnbDp6A==}
|
||||
miniflare@5.20260911.0-alpha:
|
||||
resolution: {integrity: sha512-CRieJmvHx+7rNqnA5SKdsYsER6rfkUIE/jruIUw+fLhsQ4sORfuMtr3+FQzsQ9/y8lhk061V4Fl1DFdHiyBB6g==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
minimatch@10.2.6:
|
||||
@@ -1480,8 +1480,8 @@ packages:
|
||||
reflect-metadata@0.2.2:
|
||||
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
|
||||
|
||||
resend@6.26.0:
|
||||
resolution: {integrity: sha512-vkrULozV5nGF8o7tyun/t0+qFgtPpn+cyRhVqeorhOsPUNDiSc/9p/pn8AprdHqJpYaTvftpnQgWurW++FkCdw==}
|
||||
resend@6.28.0:
|
||||
resolution: {integrity: sha512-xhRMQDZRjvJQ+sWJA3wFaoeVe5CpTxJJAfHnnULX8vifjj4QQLCLRn/AfnMZ4YmVhpFkuiX4LKxZCcj54l67Yw==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@react-email/render': '*'
|
||||
@@ -1511,9 +1511,14 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
sharp@0.35.2:
|
||||
resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==}
|
||||
sharp@0.35.4:
|
||||
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
peerDependencies:
|
||||
'@types/node': '*'
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
@@ -1573,8 +1578,8 @@ packages:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
typescript-eslint@8.69.0:
|
||||
resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==}
|
||||
typescript-eslint@8.70.0:
|
||||
resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
@@ -1621,17 +1626,17 @@ packages:
|
||||
worker-mailer@1.2.1:
|
||||
resolution: {integrity: sha512-gS2ei/mrpRqNs+AHmqxhT6vFPwCLw2qnz5ShmyGD0ULaU0Q9hxnFAcx9jhAip/MnD6+MjgnQu6hQQgA8mlOkVA==}
|
||||
|
||||
workerd@1.20260903.1:
|
||||
resolution: {integrity: sha512-xJzt2RnCy7ulOULmZy/4JLbEPg1uisp9lVoOUsEz+UVhDsTmrSQ0rBXZMGcXuhr2HCGKs89bg8nnbbzvBSX1Ig==}
|
||||
workerd@1.20260911.1:
|
||||
resolution: {integrity: sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.129.0:
|
||||
resolution: {integrity: sha512-PGPvs9UPoFrwxT0VogpESSZGvZIctAuTK3wGsLLPHtHsSgS85kNdvtpa2d14UzG8gwLWD64XUGFPGG9tOXG9VQ==}
|
||||
wrangler@4.131.1:
|
||||
resolution: {integrity: sha512-1u5FMdJAn6UOcL02cVsIITcnHrk6mC7N+RF10EkVhPL18R/o9g5BZb4PCjByL+3AsRP5wQpppCIPHhYPRmIJwg==}
|
||||
engines: {node: '>=22.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^5.20260903.1
|
||||
'@cloudflare/workers-types': ^5.20260911.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
@@ -2155,28 +2160,28 @@ snapshots:
|
||||
|
||||
'@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:
|
||||
unenv: 2.0.0-rc.24
|
||||
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
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260903.1':
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260911.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260903.1':
|
||||
'@cloudflare/workerd-linux-64@1.20260911.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260903.1':
|
||||
'@cloudflare/workerd-linux-arm64@1.20260911.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260903.1':
|
||||
'@cloudflare/workerd-windows-64@1.20260911.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workers-types@5.20260906.1': {}
|
||||
'@cloudflare/workers-types@5.20260911.1': {}
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
@@ -2331,108 +2336,108 @@ snapshots:
|
||||
|
||||
'@img/colour@1.1.0': {}
|
||||
|
||||
'@img/sharp-darwin-arm64@0.35.2':
|
||||
'@img/sharp-darwin-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.1
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-darwin-x64@0.35.2':
|
||||
'@img/sharp-darwin-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.1
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-freebsd-wasm32@0.35.2':
|
||||
'@img/sharp-freebsd-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.2
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.1':
|
||||
'@img/sharp-libvips-darwin-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-darwin-x64@1.3.1':
|
||||
'@img/sharp-libvips-darwin-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm64@1.3.1':
|
||||
'@img/sharp-libvips-linux-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-arm@1.3.1':
|
||||
'@img/sharp-libvips-linux-arm@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.1':
|
||||
'@img/sharp-libvips-linux-ppc64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.1':
|
||||
'@img/sharp-libvips-linux-riscv64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-s390x@1.3.1':
|
||||
'@img/sharp-libvips-linux-s390x@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linux-x64@1.3.1':
|
||||
'@img/sharp-libvips-linux-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.1':
|
||||
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.1':
|
||||
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm64@0.35.2':
|
||||
'@img/sharp-linux-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-arm@0.35.2':
|
||||
'@img/sharp-linux-arm@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-arm': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-ppc64@0.35.2':
|
||||
'@img/sharp-linux-ppc64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.1
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-riscv64@0.35.2':
|
||||
'@img/sharp-linux-riscv64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.1
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-s390x@0.35.2':
|
||||
'@img/sharp-linux-s390x@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.1
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linux-x64@0.35.2':
|
||||
'@img/sharp-linux-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linux-x64': 1.3.1
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-arm64@0.35.2':
|
||||
'@img/sharp-linuxmusl-arm64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-linuxmusl-x64@0.35.2':
|
||||
'@img/sharp-linuxmusl-x64@0.35.4':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-wasm32@0.35.2':
|
||||
'@img/sharp-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@emnapi/runtime': 1.11.3
|
||||
optional: true
|
||||
|
||||
'@img/sharp-webcontainers-wasm32@0.35.2':
|
||||
'@img/sharp-webcontainers-wasm32@0.35.4':
|
||||
dependencies:
|
||||
'@img/sharp-wasm32': 0.35.2
|
||||
'@img/sharp-wasm32': 0.35.4
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-arm64@0.35.2':
|
||||
'@img/sharp-win32-arm64@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-ia32@0.35.2':
|
||||
'@img/sharp-win32-ia32@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@img/sharp-win32-x64@0.35.2':
|
||||
'@img/sharp-win32-x64@0.35.4':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
@@ -2822,63 +2827,63 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/node@25.9.5':
|
||||
'@types/node@25.9.6':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
|
||||
'@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:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.69.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
'@typescript-eslint/scope-manager': 8.69.0
|
||||
'@typescript-eslint/type-utils': 8.69.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/visitor-keys': 8.69.0
|
||||
'@typescript-eslint/parser': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
'@typescript-eslint/scope-manager': 8.70.0
|
||||
'@typescript-eslint/type-utils': 8.70.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.70.0
|
||||
eslint: 9.39.1
|
||||
ignore: 7.0.8
|
||||
ignore: 7.0.9
|
||||
natural-compare: 1.4.0
|
||||
ts-api-utils: 2.5.0(typescript@5.4.5)
|
||||
typescript: 5.4.5
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/scope-manager': 8.69.0
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5)
|
||||
'@typescript-eslint/visitor-keys': 8.69.0
|
||||
'@typescript-eslint/scope-manager': 8.70.0
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
|
||||
'@typescript-eslint/visitor-keys': 8.70.0
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.1
|
||||
typescript: 5.4.5
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.4.5)
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.4.5)
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
debug: 4.4.3
|
||||
typescript: 5.4.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/scope-manager@8.69.0':
|
||||
'@typescript-eslint/scope-manager@8.70.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/visitor-keys': 8.69.0
|
||||
'@typescript-eslint/types': 8.70.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:
|
||||
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:
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5)
|
||||
'@typescript-eslint/utils': 8.69.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
|
||||
'@typescript-eslint/utils': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
debug: 4.4.3
|
||||
eslint: 9.39.1
|
||||
ts-api-utils: 2.5.0(typescript@5.4.5)
|
||||
@@ -2886,14 +2891,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@typescript-eslint/project-service': 8.69.0(typescript@5.4.5)
|
||||
'@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.4.5)
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/visitor-keys': 8.69.0
|
||||
'@typescript-eslint/project-service': 8.70.0(typescript@5.4.5)
|
||||
'@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.4.5)
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/visitor-keys': 8.70.0
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.6
|
||||
semver: 7.8.5
|
||||
@@ -2903,20 +2908,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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:
|
||||
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.1)
|
||||
'@typescript-eslint/scope-manager': 8.69.0
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5)
|
||||
'@typescript-eslint/scope-manager': 8.70.0
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
|
||||
eslint: 9.39.1
|
||||
typescript: 5.4.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/visitor-keys@8.69.0':
|
||||
'@typescript-eslint/visitor-keys@8.70.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.69.0
|
||||
'@typescript-eslint/types': 8.70.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
abort-controller@3.0.0:
|
||||
@@ -3209,7 +3214,7 @@ snapshots:
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.8: {}
|
||||
ignore@7.0.9: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
@@ -3287,15 +3292,16 @@ snapshots:
|
||||
js-base64: 3.9.3
|
||||
mime-types: 2.1.35
|
||||
|
||||
miniflare@5.20260903.0-alpha:
|
||||
miniflare@5.20260911.0-alpha(@types/node@25.9.6):
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
sharp: 0.35.2
|
||||
sharp: 0.35.4(@types/node@25.9.6)
|
||||
undici: 7.29.0
|
||||
workerd: 1.20260903.1
|
||||
workerd: 1.20260911.1
|
||||
ws: 8.21.0
|
||||
youch: 4.1.0-beta.10
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
@@ -3395,7 +3401,7 @@ snapshots:
|
||||
|
||||
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:
|
||||
postal-mime: 2.7.5
|
||||
standardwebhooks: 1.0.0
|
||||
@@ -3422,37 +3428,38 @@ snapshots:
|
||||
|
||||
semver@7.8.5: {}
|
||||
|
||||
sharp@0.35.2:
|
||||
sharp@0.35.4(@types/node@25.9.6):
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.8.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.35.2
|
||||
'@img/sharp-darwin-x64': 0.35.2
|
||||
'@img/sharp-freebsd-wasm32': 0.35.2
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.1
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm': 1.3.1
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.1
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.1
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.1
|
||||
'@img/sharp-libvips-linux-x64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.1
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.1
|
||||
'@img/sharp-linux-arm': 0.35.2
|
||||
'@img/sharp-linux-arm64': 0.35.2
|
||||
'@img/sharp-linux-ppc64': 0.35.2
|
||||
'@img/sharp-linux-riscv64': 0.35.2
|
||||
'@img/sharp-linux-s390x': 0.35.2
|
||||
'@img/sharp-linux-x64': 0.35.2
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.2
|
||||
'@img/sharp-linuxmusl-x64': 0.35.2
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.2
|
||||
'@img/sharp-win32-arm64': 0.35.2
|
||||
'@img/sharp-win32-ia32': 0.35.2
|
||||
'@img/sharp-win32-x64': 0.35.2
|
||||
'@img/sharp-darwin-arm64': 0.35.4
|
||||
'@img/sharp-darwin-x64': 0.35.4
|
||||
'@img/sharp-freebsd-wasm32': 0.35.4
|
||||
'@img/sharp-libvips-darwin-arm64': 1.3.3
|
||||
'@img/sharp-libvips-darwin-x64': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm': 1.3.3
|
||||
'@img/sharp-libvips-linux-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linux-ppc64': 1.3.3
|
||||
'@img/sharp-libvips-linux-riscv64': 1.3.3
|
||||
'@img/sharp-libvips-linux-s390x': 1.3.3
|
||||
'@img/sharp-libvips-linux-x64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
|
||||
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
|
||||
'@img/sharp-linux-arm': 0.35.4
|
||||
'@img/sharp-linux-arm64': 0.35.4
|
||||
'@img/sharp-linux-ppc64': 0.35.4
|
||||
'@img/sharp-linux-riscv64': 0.35.4
|
||||
'@img/sharp-linux-s390x': 0.35.4
|
||||
'@img/sharp-linux-x64': 0.35.4
|
||||
'@img/sharp-linuxmusl-arm64': 0.35.4
|
||||
'@img/sharp-linuxmusl-x64': 0.35.4
|
||||
'@img/sharp-webcontainers-wasm32': 0.35.4
|
||||
'@img/sharp-win32-arm64': 0.35.4
|
||||
'@img/sharp-win32-ia32': 0.35.4
|
||||
'@img/sharp-win32-x64': 0.35.4
|
||||
'@types/node': 25.9.6
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
@@ -3514,12 +3521,12 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
'@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/parser': 8.69.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
'@typescript-eslint/typescript-estree': 8.69.0(typescript@5.4.5)
|
||||
'@typescript-eslint/utils': 8.69.0(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.70.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
'@typescript-eslint/typescript-estree': 8.70.0(typescript@5.4.5)
|
||||
'@typescript-eslint/utils': 8.70.0(eslint@9.39.1)(typescript@5.4.5)
|
||||
eslint: 9.39.1
|
||||
typescript: 5.4.5
|
||||
transitivePeerDependencies:
|
||||
@@ -3556,28 +3563,29 @@ snapshots:
|
||||
|
||||
worker-mailer@1.2.1: {}
|
||||
|
||||
workerd@1.20260903.1:
|
||||
workerd@1.20260911.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20260903.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260903.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260903.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260903.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260903.1
|
||||
'@cloudflare/workerd-darwin-64': 1.20260911.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260911.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260911.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260911.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:
|
||||
'@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
|
||||
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
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20260903.1
|
||||
workerd: 1.20260911.1
|
||||
optionalDependencies:
|
||||
'@cloudflare/workers-types': 5.20260906.1
|
||||
'@cloudflare/workers-types': 5.20260911.1
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { CONSTANTS } from "../constants";
|
||||
import { WebhookSettings, RawMailRow } from "../models";
|
||||
import { commonParseMail, sendWebhook } from "../common";
|
||||
import { resolveRawEmail } from "../gzip";
|
||||
import i18n from "../i18n";
|
||||
import { getWebhookAttachments } from '../utils/webhook';
|
||||
|
||||
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.env.KV.get<WebhookSettings>(
|
||||
@@ -20,16 +22,29 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
|
||||
}
|
||||
|
||||
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
// random raw email
|
||||
const mailRow = await c.env.DB.prepare(
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const settings = await c.req.json<WebhookSettings & { mail_id?: number }>().catch(() => null);
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
||||
return c.text(msgs.InvalidRequestBodyMsg, 400);
|
||||
}
|
||||
const requestedMailId = settings.mail_id;
|
||||
if (requestedMailId !== undefined && (!Number.isSafeInteger(requestedMailId) || requestedMailId <= 0)) {
|
||||
return c.text(msgs.InvalidMailIdMsg, 400);
|
||||
}
|
||||
const mailRow = requestedMailId !== undefined ? await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails WHERE id = ?`
|
||||
).bind(requestedMailId).first<RawMailRow>() : await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails ORDER BY RANDOM() LIMIT 1`
|
||||
).first<RawMailRow>();
|
||||
const mailId = mailRow?.id;
|
||||
if (requestedMailId !== undefined && !mailRow) {
|
||||
return c.text(msgs.MailNotFoundMsg, 404);
|
||||
}
|
||||
const raw = mailRow ? await resolveRawEmail(mailRow) : "";
|
||||
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const res = await sendWebhook(settings, {
|
||||
attachments: await getWebhookAttachments(c.env, mailRow, parsedEmail?.attachments),
|
||||
id: mailId || "0",
|
||||
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
|
||||
from: parsedEmail?.sender || "test@test.com",
|
||||
|
||||
@@ -4,6 +4,7 @@ import utils from './utils';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { isS3Enabled } from './mails_api/s3_attachment';
|
||||
import { isAnySendMailEnabled } from './common';
|
||||
import { getWebhookAttachment } from './open_api/webhook_attachment';
|
||||
|
||||
const api = new Hono<HonoCustomType>
|
||||
|
||||
@@ -72,4 +73,6 @@ api.get('/open_api/settings', async (c) => {
|
||||
});
|
||||
})
|
||||
|
||||
api.get('/open_api/a/:mail_id/:index/:expires/:signature', getWebhookAttachment)
|
||||
|
||||
export { api }
|
||||
|
||||
+11
-14
@@ -7,6 +7,7 @@ import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { AddressCreationSettings, AdminWebhookSettings, ExtractResult, WebhookMail, WebhookSettings } from './models';
|
||||
import i18n from './i18n';
|
||||
import { formatWebhookBody, getWebhookAttachments } from './utils/webhook';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||
@@ -838,22 +839,13 @@ export async function sendWebhook(
|
||||
settings: WebhookSettings, formatMap: WebhookMail
|
||||
): Promise<{ success: boolean, message?: string }> {
|
||||
// send webhook
|
||||
let body = settings.body;
|
||||
for (const key of Object.keys(formatMap)) {
|
||||
body = body.replace(
|
||||
new RegExp(`\\$\\{${key}\\}`, "g"),
|
||||
JSON.stringify(
|
||||
formatMap[key as keyof WebhookMail]
|
||||
).replace(/^"(.*)"$/, '$1')
|
||||
);
|
||||
}
|
||||
const body = formatWebhookBody(settings.body, formatMap);
|
||||
const response = await fetch(settings.url, {
|
||||
method: settings.method,
|
||||
headers: JSON.parse(settings.headers),
|
||||
body: body
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.log("send webhook error", settings.url, settings.method, settings.headers, body);
|
||||
console.log("send webhook error", response.status, response.statusText);
|
||||
return { success: false, message: `send webhook error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
@@ -864,7 +856,7 @@ export async function triggerWebhook(
|
||||
c: Context<HonoCustomType>,
|
||||
address: string,
|
||||
parsedEmailContext: ParsedEmailContext,
|
||||
message_id: string | null,
|
||||
storedMailId: number | undefined,
|
||||
aiExtract?: ExtractResult | null
|
||||
): Promise<void> {
|
||||
if (!c.env.KV || !getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
@@ -893,17 +885,22 @@ export async function triggerWebhook(
|
||||
if (webhookList.length === 0) {
|
||||
return
|
||||
}
|
||||
const mailId = await c.env.DB.prepare(
|
||||
`SELECT id FROM raw_mails where address = ? and message_id = ?`
|
||||
).bind(address, message_id).first<string>("id");
|
||||
const mailRow = storedMailId ? await c.env.DB.prepare(
|
||||
`SELECT id, address, created_at FROM raw_mails WHERE id = ? AND address = ?`
|
||||
).bind(storedMailId, address).first<{ id: number, address: string, created_at: string }>() : null;
|
||||
const mailId = String(mailRow?.id || '');
|
||||
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const needsAttachments = webhookList.some(settings => settings.body.includes('${attachment'));
|
||||
const attachments = needsAttachments
|
||||
? await getWebhookAttachments(c.env, mailRow, parsedEmail?.attachments) : [];
|
||||
const usableAiExtract = aiExtract?.type !== "none" && aiExtract?.result
|
||||
? aiExtract
|
||||
: null;
|
||||
const webhookMail = {
|
||||
id: mailId || "",
|
||||
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
|
||||
attachments,
|
||||
from: parsedEmail?.sender || "",
|
||||
to: address,
|
||||
subject: parsedEmail?.subject || "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v' + '1.12.0',
|
||||
VERSION: 'v' + '1.13.0',
|
||||
|
||||
// DB Version
|
||||
DB_VERSION_KEY: 'db_version',
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* AI Email Extraction Module
|
||||
*
|
||||
* This module provides email content analysis using Cloudflare Workers AI.
|
||||
* It extracts important information like verification codes, authentication links,
|
||||
* service links, and subscription management links from email content.
|
||||
* This module provides email content analysis, either with built-in local rules
|
||||
* (verification codes only) or with Cloudflare Workers AI, which also extracts
|
||||
* authentication links, service links, and subscription management links.
|
||||
*/
|
||||
|
||||
import { commonParseMail } from "../common";
|
||||
import { extractCode } from "./extract_code";
|
||||
import { extractCode, joinSubjectAndBody } from "./extract_code";
|
||||
import { ExtractMode, resolveExtractMode } from "./extract_mode";
|
||||
import { getBooleanValue, getJsonSetting } from "../utils";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { Context } from "hono";
|
||||
@@ -141,7 +142,7 @@ async function extractWithCloudflareAI(
|
||||
|
||||
/**
|
||||
* Persist an extraction result to the raw_mails metadata column.
|
||||
* Shared by the Workers AI path and the regex fallback path.
|
||||
* Shared by the Workers AI mode and the local rule mode.
|
||||
*
|
||||
* @param env - Cloudflare Workers environment bindings
|
||||
* @param message_id - The email message ID
|
||||
@@ -226,11 +227,24 @@ function getEmailContentForExtract(parsedEmail: Awaited<ReturnType<typeof common
|
||||
return htmlToTextForAi(parsedEmail.html) || parsedEmail.html;
|
||||
}
|
||||
|
||||
function isAddressInAiAllowlist(settings: AiExtractSettings | null | undefined, address: string): boolean {
|
||||
if (!settings?.enableAllowList || !settings.allowList?.length) return true;
|
||||
return settings.allowList.some(pattern => {
|
||||
if (!pattern.includes('*')) return address === pattern;
|
||||
const escapedPattern = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
return new RegExp('^' + escapedPattern + '$').test(address);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main extraction function
|
||||
* Checks if extraction is enabled, processes the email content, and saves to database.
|
||||
* Uses Cloudflare Workers AI when the `AI` binding is available; otherwise falls back
|
||||
* to a built-in regex extractor that surfaces verification codes only.
|
||||
* `AI_EXTRACT_MODE` selects the preferred extractor:
|
||||
* - `local` (default): built-in rules, verification codes only, content never sent to AI
|
||||
* - `ai`: Cloudflare Workers AI, verification codes and links; if the address is not
|
||||
* in the AI allowlist, only the AI call is skipped and local code extraction still runs
|
||||
*
|
||||
* @param parsedEmailContext - The parsed email context
|
||||
* @param env - Cloudflare Workers environment bindings
|
||||
@@ -250,53 +264,50 @@ export async function extractEmailInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check allowlist if enabled (applies to both AI and the regex fallback)
|
||||
const mode = resolveExtractMode(env.AI_EXTRACT_MODE);
|
||||
if (!mode) {
|
||||
console.error(`Email extraction skipped: unsupported AI_EXTRACT_MODE "${env.AI_EXTRACT_MODE}", expected "local" or "ai"`);
|
||||
return null;
|
||||
}
|
||||
const aiSettings = await getJsonSetting<AiExtractSettings>(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
CONSTANTS.AI_EXTRACT_SETTINGS_KEY
|
||||
);
|
||||
const isAiAllowed = isAddressInAiAllowlist(aiSettings, address);
|
||||
|
||||
if (aiSettings?.enableAllowList && aiSettings.allowList?.length > 0) {
|
||||
const isAllowed = aiSettings.allowList.some(pattern => {
|
||||
// Support wildcard matching
|
||||
if (pattern.includes('*')) {
|
||||
// Escape special regex characters except *
|
||||
const escapedPattern = pattern
|
||||
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*');
|
||||
const regex = new RegExp('^' + escapedPattern + '$');
|
||||
return regex.test(address);
|
||||
}
|
||||
// Exact match
|
||||
return address === pattern;
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
console.log(`Email extraction skipped for ${address}: not in allowlist`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse email to get content (shared by the AI path and the regex fallback)
|
||||
// Parse email to get content (shared by both modes)
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const emailContent = getEmailContentForExtract(parsedEmail);
|
||||
|
||||
if (!emailContent) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Fallback: when no Workers AI binding is available, use a built-in regex
|
||||
// extractor so self-hosted deployments without Workers AI still surface
|
||||
// verification codes. Telegram / webhook reuse the same ExtractResult.
|
||||
if (!env.AI) {
|
||||
const code = extractCode(emailContent);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
const result: ExtractResult = { type: 'auth_code', result: code, result_text: '' };
|
||||
await saveExtractMetadata(env, message_id, result);
|
||||
console.log(`Regex code extraction completed for ${message_id}`);
|
||||
return result;
|
||||
if (!emailContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Truncate content if too long (max 4000 characters to avoid token limits)
|
||||
|
||||
@@ -48,4 +48,8 @@ export const remove_attachment_if_need = async (
|
||||
});
|
||||
}
|
||||
parsedEmailContext.rawEmail = msg.asRaw();
|
||||
parsedEmailContext.parsedEmail = {
|
||||
...parsedEmail,
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,61 +1,205 @@
|
||||
/**
|
||||
* Regex-based verification code extraction.
|
||||
* Local rule-based verification code extraction.
|
||||
*
|
||||
* Extracts verification codes (4-12 digit / alphanumeric) from email text.
|
||||
* Covers common formats across English, Chinese, Japanese and Korean.
|
||||
* Extracts verification codes from email text without calling any AI model,
|
||||
* so mail content never leaves the regex engine inside the Worker.
|
||||
*
|
||||
* This is a zero-dependency fallback used when Workers AI is not bound, so
|
||||
* self-hosted deployments without Workers AI still surface verification codes.
|
||||
* Pipeline:
|
||||
* 1. Normalize: strip zero-width characters, fold full-width digits and letters,
|
||||
* and remove URLs / email addresses so their digits are never candidates.
|
||||
* 2. Try keyword-anchored patterns in priority order (keyword before code,
|
||||
* code before keyword, keyword on the previous line).
|
||||
* 3. Only for verification-looking mails, fall back to a code on its own line
|
||||
* or right after "use / enter / 输入".
|
||||
*
|
||||
* Design principles (learned from QA):
|
||||
* 1. Prefer codes that appear after an explicit code keyword.
|
||||
* 2. Reject plausible dates/years (e.g. "2026", "20260411") — too common as
|
||||
* date markers in subject lines, almost never a real verification code.
|
||||
* 3. Allow multi-character delimiters (a keyword followed by a colon or "is").
|
||||
* 4. Fall back to standalone digits ONLY if no keyword-guided match found
|
||||
* AND the digits don't look like a year or YYYYMMDD date.
|
||||
* Every candidate must look like a code: 4-8 digits (not a year or YYYYMMDD
|
||||
* date), or 4-10 letters and digits containing at least one digit. Digits that
|
||||
* belong to amounts, decimals, times, phone numbers or longer numbers are never
|
||||
* taken as codes.
|
||||
*
|
||||
* Some keyword lists and test messages are adapted from 2FHey
|
||||
* (https://github.com/SoFriendly/2fhey, CC0-1.0).
|
||||
*/
|
||||
|
||||
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 {
|
||||
// DELIM: at least one explicit delimiter must follow the code keyword before
|
||||
// the code itself. Allowed delimiters: an ASCII or full-width colon, the word
|
||||
// "is", or a common CJK delimiter particle (see the DELIM regex below).
|
||||
// Multiple delimiters in sequence are OK. Whitespace around them is allowed.
|
||||
//
|
||||
// Why mandatory: without a delimiter, "verification code Your email" would
|
||||
// capture "Your" as a false alphanumeric code. Bare "verification code 123456"
|
||||
// without any delimiter still falls through to the standalone-digit fallback.
|
||||
const DELIM = '\\s*(?:[::]|\\bis\\b|是|为|です)[\\s::]*';
|
||||
if (!text) return null;
|
||||
const normalized = normalizeText(text.slice(0, MAX_TEXT_LENGTH));
|
||||
|
||||
// Keyword groups — labels that precede a verification code
|
||||
const CN_JA_KO_KW = '验证码|认证码|确认码|認証コード|인증\\s*코드|코드';
|
||||
const EN_KW = 'verification\\s*code|confirm(?:ation)?\\s*code|security\\s*code|passcode|OTP|pin\\s*code';
|
||||
const ALL_KW = `${CN_JA_KO_KW}|${EN_KW}`;
|
||||
|
||||
const keywordPatterns = [
|
||||
// 1. "code is 123456" / "code: 123456" — numeric preferred (bare "code" keyword)
|
||||
new RegExp(`\\bcode${DELIM}(\\d{4,12})\\b`, 'i'),
|
||||
// 2. Full keyword + mandatory delimiter + digits
|
||||
new RegExp(`(?:${ALL_KW})${DELIM}(\\d{4,12})\\b`, 'i'),
|
||||
// 3. Bare "code" + delimiter + alphanumeric
|
||||
new RegExp(`\\bcode${DELIM}([A-Za-z0-9]{4,12})\\b`, 'i'),
|
||||
// 4. Full keyword + delimiter + alphanumeric
|
||||
new RegExp(`(?:${ALL_KW})${DELIM}([A-Za-z0-9]{4,12})\\b`, 'i'),
|
||||
];
|
||||
|
||||
for (const pattern of keywordPatterns) {
|
||||
const match = text.match(pattern);
|
||||
if (match?.[1] && !looksLikeDate(match[1])) return match[1];
|
||||
for (const pattern of PATTERNS) {
|
||||
const code = findCode(normalized, pattern);
|
||||
if (code) return code;
|
||||
}
|
||||
|
||||
// Fallback: standalone digit sequence, but reject year/date patterns
|
||||
const standaloneMatch = text.match(/(?:^|\s)(\d{4,12})(?:\s|$|\.|,)/m);
|
||||
if (standaloneMatch?.[1] && !looksLikeDate(standaloneMatch[1])) {
|
||||
return standaloneMatch[1];
|
||||
}
|
||||
if (!VERIFY_CONTEXT.test(normalized)) return null;
|
||||
|
||||
for (const pattern of FALLBACK_PATTERNS) {
|
||||
const code = findCode(normalized, pattern);
|
||||
if (code) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeText(text: string): string {
|
||||
return text
|
||||
// Zero-width and soft-hyphen characters sometimes split codes, e.g. "7\u200B4\u200C9"
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF\u00AD]/g, '')
|
||||
// Full-width digits and letters: 123456 → 123456. CJK punctuation is kept,
|
||||
// so "123456,5分钟" is not read as the decimal "123456,5".
|
||||
.replace(/[\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 0xFEE0))
|
||||
.replace(/\r\n?/g, '\n')
|
||||
// URLs and email addresses: their digits are never verification codes
|
||||
.replace(/\bhttps?:\/\/[^\s<>"']+/gi, ' ')
|
||||
.replace(/\bwww\.[^\s<>"']+/gi, ' ')
|
||||
.replace(/[\w.+-]{1,64}@[\w-]{1,63}(?:\.[\w-]{1,63}){1,8}/g, ' ');
|
||||
}
|
||||
|
||||
function findCode(text: string, pattern: RegExp): string | null {
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const raw = match.slice(1).find(group => group !== undefined);
|
||||
const code = raw?.replace(/[ -]/g, '');
|
||||
if (code && isPlausibleCode(code)) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPlausibleCode(code: string): boolean {
|
||||
if (/^\d+$/.test(code)) {
|
||||
return code.length >= 4 && code.length <= 8 && !looksLikeDate(code);
|
||||
}
|
||||
// Alphanumeric codes must contain a digit, so words like "EXPIRED" or "NEVER" are not codes.
|
||||
return code.length >= 4 && code.length <= 10 && /\d/.test(code) && /^[A-Za-z0-9]+$/.test(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic: does this digit sequence look like a date/year we should reject?
|
||||
* - 4 digits matching 19xx or 20xx → year (e.g. 2026)
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -66,18 +66,18 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
try {
|
||||
const { success } = await storeRawMail(
|
||||
env, message.from, toAddress, message_id, parsedEmailContext.rawEmail
|
||||
);
|
||||
const storedMailId = await storeRawMail(
|
||||
env, message.from, toAddress, message_id, parsedEmailContext.rawEmail
|
||||
).then(({ success, meta }) => {
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${toAddress}`);
|
||||
console.error(`Failed save message from ${message.from} to ${toAddress}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return success ? meta.last_row_id : undefined;
|
||||
}).catch((error) => {
|
||||
console.error("save email error", error);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// forward email
|
||||
await forwardEmail(message, env);
|
||||
@@ -98,7 +98,7 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
try {
|
||||
await triggerWebhook(
|
||||
{ env: env } as Context<HonoCustomType>,
|
||||
toAddress, parsedEmailContext, message_id, aiExtractResult
|
||||
toAddress, parsedEmailContext, storedMailId, aiExtractResult
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("send webhook error", error);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LocaleMessages } from "./type";
|
||||
|
||||
const messages: LocaleMessages = {
|
||||
InvalidRequestBodyMsg: "Invalid request body",
|
||||
InvalidMailIdMsg: "Invalid mail ID",
|
||||
MailNotFoundMsg: "Mail not found",
|
||||
CustomAuthPasswordMsg: "You have enabled the private site password, please provide the password",
|
||||
UserTokenExpiredMsg: "Your token has expired, please login again",
|
||||
UserAcceesTokenExpiredMsg: "Your access token has expired, please refresh the page",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export type LocaleMessages = {
|
||||
InvalidRequestBodyMsg: string
|
||||
InvalidMailIdMsg: string
|
||||
MailNotFoundMsg: string
|
||||
CustomAuthPasswordMsg: string
|
||||
UserTokenExpiredMsg: string
|
||||
UserAcceesTokenExpiredMsg: string
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { LocaleMessages } from "./type";
|
||||
|
||||
const messages: LocaleMessages = {
|
||||
InvalidRequestBodyMsg: "无效的请求体",
|
||||
InvalidMailIdMsg: "无效的邮件 ID",
|
||||
MailNotFoundMsg: "邮件不存在",
|
||||
CustomAuthPasswordMsg: "你已启用私有站点密码,请提供密码",
|
||||
UserTokenExpiredMsg: "您的令牌已过期, 请重新登录",
|
||||
UserAcceesTokenExpiredMsg: "您的访问令牌已过期, 请刷新页面",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CONSTANTS } from "../constants";
|
||||
import { AdminWebhookSettings, WebhookSettings, RawMailRow } from "../models";
|
||||
import { commonParseMail, sendWebhook } from "../common";
|
||||
import { resolveRawEmail } from "../gzip";
|
||||
import { getWebhookAttachments } from '../utils/webhook';
|
||||
import i18n from "../i18n";
|
||||
|
||||
|
||||
@@ -35,17 +36,30 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
|
||||
}
|
||||
|
||||
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const settings = await c.req.json<WebhookSettings & { mail_id?: number }>().catch(() => null);
|
||||
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
||||
return c.text(msgs.InvalidRequestBodyMsg, 400);
|
||||
}
|
||||
const requestedMailId = settings.mail_id;
|
||||
if (requestedMailId !== undefined && (!Number.isSafeInteger(requestedMailId) || requestedMailId <= 0)) {
|
||||
return c.text(msgs.InvalidMailIdMsg, 400);
|
||||
}
|
||||
const { address } = c.get("jwtPayload");
|
||||
// random raw email
|
||||
const mailRow = await c.env.DB.prepare(
|
||||
const mailRow = requestedMailId !== undefined ? await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails WHERE id = ? AND address = ?`
|
||||
).bind(requestedMailId, address).first<RawMailRow>() : await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails WHERE address = ? ORDER BY RANDOM() LIMIT 1`
|
||||
).bind(address).first<RawMailRow>();
|
||||
const mailId = mailRow?.id;
|
||||
if (requestedMailId !== undefined && !mailRow) {
|
||||
return c.text(msgs.MailNotFoundMsg, 404);
|
||||
}
|
||||
const raw = mailRow ? await resolveRawEmail(mailRow) : "";
|
||||
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const res = await sendWebhook(settings, {
|
||||
attachments: await getWebhookAttachments(c.env, mailRow, parsedEmail?.attachments),
|
||||
id: mailId || "0",
|
||||
url: c.env.FRONTEND_URL ? `${c.env.FRONTEND_URL}?mail_id=${mailId}` : "",
|
||||
from: parsedEmail?.sender || "test@test.com",
|
||||
|
||||
@@ -26,6 +26,7 @@ export class AdminWebhookSettings {
|
||||
export type WebhookMail = {
|
||||
id: string;
|
||||
url?: string;
|
||||
attachments?: { filename: string, mimeType: string, url: string }[];
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Context } from 'hono';
|
||||
|
||||
import { resolveRawEmail } from '../gzip';
|
||||
import { RawMailRow } from '../models';
|
||||
import { getBooleanValue } from '../utils';
|
||||
import i18n from '../i18n';
|
||||
|
||||
import { commonParseMail } from '../common';
|
||||
import {
|
||||
WEBHOOK_ATTACHMENT_TTL_SECONDS, SAFE_INLINE_IMAGE_TYPES,
|
||||
decodeBase32, getSigningKey, getSignaturePayload
|
||||
} from '../utils/webhook';
|
||||
|
||||
export const getWebhookAttachment = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<Response> => {
|
||||
if (!getBooleanValue(c.env.ENABLE_WEBHOOK)) {
|
||||
return c.text(i18n.getMessagesbyContext(c).WebhookNotEnabledMsg, 403);
|
||||
}
|
||||
const mailId = Number(c.req.param('mail_id'));
|
||||
const index = Number(c.req.param('index'));
|
||||
const expires = Number(c.req.param('expires'));
|
||||
const signatureValue = c.req.param('signature') || '';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (
|
||||
!Number.isSafeInteger(mailId) || mailId <= 0
|
||||
|| !Number.isSafeInteger(index) || index < 0
|
||||
|| !Number.isSafeInteger(expires)
|
||||
|| expires <= now || expires > now + WEBHOOK_ATTACHMENT_TTL_SECONDS
|
||||
|| !/^[a-z2-7]{51}[aq]$/i.test(signatureValue)
|
||||
) {
|
||||
return c.text('Not Found', 404);
|
||||
}
|
||||
|
||||
const mail = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails WHERE id = ?`
|
||||
).bind(mailId).first<RawMailRow>();
|
||||
if (!mail?.address || !mail.created_at) return c.text('Not Found', 404);
|
||||
|
||||
const valid = await crypto.subtle.verify(
|
||||
'HMAC', await getSigningKey(c.env.JWT_SECRET), decodeBase32(signatureValue),
|
||||
getSignaturePayload(mailId, mail.address, mail.created_at, expires, index)
|
||||
);
|
||||
if (!valid) return c.text('Not Found', 404);
|
||||
|
||||
const rawEmail = await resolveRawEmail(mail);
|
||||
const parsedEmail = await commonParseMail({ rawEmail });
|
||||
const attachment = parsedEmail?.attachments?.[index];
|
||||
if (!attachment) return c.text('Not Found', 404);
|
||||
|
||||
const inline = SAFE_INLINE_IMAGE_TYPES.has(attachment.mimeType.toLowerCase());
|
||||
const filename = encodeURIComponent(attachment.filename).replace(/[!'()*]/g,
|
||||
character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
return new Response(Uint8Array.from(attachment.content).buffer, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Disposition': inline ? 'inline' : `attachment; filename*=UTF-8''${filename}`,
|
||||
'Content-Type': inline ? attachment.mimeType : 'application/octet-stream',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
},
|
||||
});
|
||||
}
|
||||
Vendored
+2
@@ -114,9 +114,11 @@ type Bindings = {
|
||||
|
||||
// webhook config
|
||||
FRONTEND_URL: string | undefined
|
||||
BACKEND_URL: string | undefined
|
||||
|
||||
// AI extraction config
|
||||
ENABLE_AI_EMAIL_EXTRACT: string | boolean | undefined
|
||||
AI_EXTRACT_MODE: string | undefined
|
||||
AI_EXTRACT_MODEL: string | undefined
|
||||
|
||||
// gzip compression for raw_mails
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { RawMailRow, WebhookMail } from '../models';
|
||||
|
||||
export const WEBHOOK_ATTACHMENT_TTL_SECONDS = 24 * 60 * 60;
|
||||
export const SAFE_INLINE_IMAGE_TYPES = new Set([
|
||||
'image/png', 'image/jpeg', 'image/gif', 'image/webp'
|
||||
]);
|
||||
const textEncoder = new TextEncoder();
|
||||
let signingKey: { secret: string, key: Promise<CryptoKey> } | undefined;
|
||||
|
||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
||||
|
||||
const encodeBase32 = (value: Uint8Array): string => {
|
||||
const bits = Array.from(value, byte => byte.toString(2).padStart(8, '0')).join('');
|
||||
return (bits.match(/.{1,5}/g) || []).map(group =>
|
||||
BASE32_ALPHABET[parseInt(group.padEnd(5, '0'), 2)]
|
||||
).join('');
|
||||
}
|
||||
|
||||
export const decodeBase32 = (value: string): Uint8Array => {
|
||||
const bits = Array.from(value.toLowerCase(), character =>
|
||||
BASE32_ALPHABET.indexOf(character).toString(2).padStart(5, '0')
|
||||
).join('');
|
||||
return Uint8Array.from(bits.match(/.{8}/g) || [], byte => parseInt(byte, 2));
|
||||
}
|
||||
|
||||
export const getSigningKey = (secret: string): Promise<CryptoKey> => {
|
||||
if (signingKey?.secret === secret) return signingKey.key;
|
||||
const key = crypto.subtle.importKey(
|
||||
'raw', textEncoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']
|
||||
);
|
||||
signingKey = { secret, key };
|
||||
return key;
|
||||
}
|
||||
|
||||
export const getSignaturePayload = (
|
||||
mailId: number, address: string, createdAt: string, expires: number, index: number
|
||||
): Uint8Array => textEncoder.encode(JSON.stringify([
|
||||
'webhook-attachment-v1', mailId, address, createdAt, expires, index
|
||||
]));
|
||||
|
||||
export const createWebhookAttachmentPath = async (
|
||||
secret: string, mailId: number, address: string, createdAt: string, index: number
|
||||
): Promise<string> => {
|
||||
const expires = Math.floor(Date.now() / 1000) + WEBHOOK_ATTACHMENT_TTL_SECONDS;
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC', await getSigningKey(secret),
|
||||
getSignaturePayload(mailId, address, createdAt, expires, index)
|
||||
);
|
||||
return `/open_api/a/${mailId}/${index}/${expires}/${encodeBase32(new Uint8Array(signature))}`;
|
||||
}
|
||||
|
||||
export const getWebhookAttachments = async (
|
||||
env: Bindings, mail: RawMailRow | null, attachments: ParsedEmailAttachment[] = []
|
||||
): Promise<NonNullable<WebhookMail['attachments']>> => {
|
||||
if (!mail?.address || !mail.created_at) return [];
|
||||
const { id, address, created_at } = mail;
|
||||
const backendUrl = env.BACKEND_URL?.replace(/\/$/, '');
|
||||
return Promise.all(attachments.map(async (attachment, index) => ({
|
||||
filename: attachment.filename,
|
||||
mimeType: attachment.mimeType,
|
||||
url: backendUrl
|
||||
? `${backendUrl}${await createWebhookAttachmentPath(env.JWT_SECRET, id, address, created_at, index)}`
|
||||
: '',
|
||||
})));
|
||||
}
|
||||
|
||||
export const formatWebhookBody = (body: string, mail: WebhookMail): string => {
|
||||
const attachments = mail.attachments || [];
|
||||
const linkedAttachments = attachments.filter(attachment => attachment.url);
|
||||
const formatMap = {
|
||||
...mail,
|
||||
attachments,
|
||||
attachmentLinks: linkedAttachments.map(attachment => attachment.url).join('\n'),
|
||||
attachmentMarkdownLinks: linkedAttachments.map(attachment => {
|
||||
const filename = attachment.filename.replace(/[\r\n]/g, ' ').replace(/[\\[\]()`*_!<>]/g, '\\$&');
|
||||
return `[${filename}](${attachment.url})`;
|
||||
}).join('\n'),
|
||||
};
|
||||
return body.replace(/\$\{(\w+)\}/g, (placeholder, key: string) => {
|
||||
if (!Object.hasOwn(formatMap, key)) return placeholder;
|
||||
return JSON.stringify(formatMap[key as keyof typeof formatMap]).replace(/^"(.*)"$/, '$1');
|
||||
});
|
||||
}
|
||||
@@ -136,6 +136,8 @@ ENABLE_AUTO_REPLY = false
|
||||
# """
|
||||
# Frontend URL
|
||||
# FRONTEND_URL = "https://xxxx.xxx"
|
||||
# Backend public URL for signed webhook attachment links
|
||||
# BACKEND_URL = "https://temp-email-api.example.com"
|
||||
# Enable check junk mail
|
||||
# ENABLE_CHECK_JUNK_MAIL = false
|
||||
# junk mail check list: reject registered failure/error results; none and SPF/DKIM neutral are treated as absent
|
||||
@@ -148,11 +150,14 @@ ENABLE_AUTO_REPLY = false
|
||||
# REMOVE_ALL_ATTACHMENT = true
|
||||
# enable gzip compressed email storage in raw_blob column (run db_migration first)
|
||||
# ENABLE_MAIL_GZIP = true
|
||||
# AI email extraction, automatically extract verification codes, auth links, etc.
|
||||
# Email extraction, automatically extract verification codes, auth links, etc.
|
||||
# ENABLE_AI_EMAIL_EXTRACT = true
|
||||
# Extraction mode: "local" (default, built-in rules, verification codes only, mail never sent to AI)
|
||||
# or "ai" (prefer Workers AI for codes and links; allowlist misses fall back to local code extraction)
|
||||
# AI_EXTRACT_MODE = "local"
|
||||
# AI model name, choose from https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models
|
||||
# Recommended JSON Mode model: "@cf/meta/llama-3.1-8b-instruct-fast"
|
||||
# Note: "@cf/meta/llama-3.1-8b-instruct" will be deprecated by Cloudflare on 2026-05-30
|
||||
# Note: "@cf/meta/llama-3.1-8b-instruct" was deprecated by Cloudflare on 2026-05-30
|
||||
# AI_EXTRACT_MODEL = "@cf/meta/llama-3.1-8b-instruct-fast"
|
||||
# Calling other woker to process email
|
||||
# ENABLE_ANOTHER_WORKER = false
|
||||
|
||||
Reference in New Issue
Block a user