mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-07-06 14:52:09 +08:00
Compare commits
19 Commits
v1.4.0
...
03965f3612
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03965f3612 | ||
|
|
64d11799b3 | ||
|
|
10f1f1f32b | ||
|
|
e77ab12140 | ||
|
|
79b9835fa2 | ||
|
|
6c58cd3c2e | ||
|
|
eeea512ab1 | ||
|
|
e35c246757 | ||
|
|
e7df77cac0 | ||
|
|
9ee21da8a9 | ||
|
|
5bb053fb7b | ||
|
|
7d880ef340 | ||
|
|
e6cc8e2ffd | ||
|
|
94c606959f | ||
|
|
75236e6a53 | ||
|
|
13c3879033 | ||
|
|
c5893a2944 | ||
|
|
5f3762ef58 | ||
|
|
10873e7887 |
27
.claude/skills/release/SKILL.md
Normal file
27
.claude/skills/release/SKILL.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: release
|
||||
description: Create a GitHub release for cloudflare_temp_email project. Use when the user asks to create a release, publish a version, tag a release, or make a new release. Reads CHANGELOG.md for release content, collects merged PRs via `gh` CLI, and creates a properly formatted GitHub release.
|
||||
---
|
||||
|
||||
# Release Workflow
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read version**: Get current version from `worker/package.json` (`"version"` field) and the latest release tag via `gh release list --limit 1`.
|
||||
2. **Read CHANGELOG**: Read `CHANGELOG.md` for the current version section (e.g. `## v1.4.0(main)`). Verify content matches `CHANGELOG_EN.md`. If entries are missing from either file, notify the user.
|
||||
3. **Collect PRs**: Get the last release tag timestamp, then filter merged PRs by time:
|
||||
```bash
|
||||
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
|
||||
SINCE="$(git show -s --format=%cI "$TAG")"
|
||||
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:
|
||||
- Copy changelog sections verbatim (Features, Bug Fixes, Testing, Improvements). Omit empty sections.
|
||||
- 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**:
|
||||
- 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.
|
||||
42
.claude/skills/release/references/release-template.md
Normal file
42
.claude/skills/release/references/release-template.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Release Notes Template
|
||||
|
||||
Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本段落提取:
|
||||
|
||||
```markdown
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |模块| 描述
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |模块| 描述
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |模块| 描述
|
||||
|
||||
### Improvements
|
||||
|
||||
- style/refactor/perf/docs: |模块| 描述
|
||||
|
||||
### [更新或者部署网页不生效请如图勾选清理缓存](https://github.com/dreamhunter2333/cloudflare_temp_email/discussions/487)
|
||||
|
||||
<details>
|
||||
<summary>PRs</summary>
|
||||
|
||||
* PR title by @author in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/NUMBER
|
||||
|
||||
</details>
|
||||
|
||||
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/vOLD...vNEW
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Sections without entries should be omitted
|
||||
- PRs section uses `<details>` to collapse by default
|
||||
- PRs are sorted by PR number ascending
|
||||
- The cache clearing discussion link is always included
|
||||
- Release title and tag use format `vX.Y.Z`
|
||||
25
.github/config/mail-parser-wasm-worker.patch
vendored
25
.github/config/mail-parser-wasm-worker.patch
vendored
@@ -1,16 +1,14 @@
|
||||
diff --git a/worker/src/common.ts b/worker/src/common.ts
|
||||
index bd9bcc9..e7e2748 100644
|
||||
index 9b758f0..e2150b5 100644
|
||||
--- a/worker/src/common.ts
|
||||
+++ b/worker/src/common.ts
|
||||
@@ -273,23 +273,23 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
}
|
||||
const raw_mail = parsedEmailContext.rawEmail;
|
||||
// TODO: WASM parse email
|
||||
// NOTE: WASM parse email
|
||||
- // try {
|
||||
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
+ try {
|
||||
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
|
||||
-
|
||||
- // const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
- // parsedEmailContext.parsedEmail = {
|
||||
- // sender: parsedEmail.sender || "",
|
||||
@@ -20,11 +18,20 @@ index bd9bcc9..e7e2748 100644
|
||||
- // (header) => ({ key: header.key, value: header.value })
|
||||
- // ) || [],
|
||||
- // html: parsedEmail.body_html || "",
|
||||
- // attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
- // filename: att.filename || "attachment",
|
||||
- // mimeType: att.content_type || "application/octet-stream",
|
||||
- // content: att.content,
|
||||
- // disposition: "attachment",
|
||||
- // })),
|
||||
- // };
|
||||
- // return parsedEmailContext.parsedEmail;
|
||||
- // } catch (e) {
|
||||
- // console.error("Failed use mail-parser-wasm-worker to parse email", e);
|
||||
- // }
|
||||
+ try {
|
||||
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
+
|
||||
+ const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
+ parsedEmailContext.parsedEmail = {
|
||||
+ sender: parsedEmail.sender || "",
|
||||
@@ -34,6 +41,12 @@ index bd9bcc9..e7e2748 100644
|
||||
+ (header) => ({ key: header.key, value: header.value })
|
||||
+ ) || [],
|
||||
+ html: parsedEmail.body_html || "",
|
||||
+ attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
+ filename: att.filename || "attachment",
|
||||
+ mimeType: att.content_type || "application/octet-stream",
|
||||
+ content: att.content,
|
||||
+ disposition: "attachment",
|
||||
+ })),
|
||||
+ };
|
||||
+ return parsedEmailContext.parsedEmail;
|
||||
+ } catch (e) {
|
||||
|
||||
18
.github/workflows/backend_deploy.yaml
vendored
18
.github/workflows/backend_deploy.yaml
vendored
@@ -16,24 +16,25 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Backend for ${{ github.ref_name }}
|
||||
run: |
|
||||
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
|
||||
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
|
||||
|
||||
if [ -n "$use_worker_assets" ]; then
|
||||
cd frontend/
|
||||
pnpm install --no-frozen-lockfile
|
||||
@@ -49,8 +50,11 @@ jobs:
|
||||
|
||||
export debug_mode=${{ secrets.DEBUG_MODE }}
|
||||
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
|
||||
|
||||
cd worker/
|
||||
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
|
||||
# ✅ 修复核心:使用环境变量写入,避免 Shell 解析特殊字符
|
||||
printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml
|
||||
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
if [ -n "$use_mail_wasm_parser" ]; then
|
||||
@@ -74,3 +78,5 @@ jobs:
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
# ✅ 将 secret 映射到环境变量中
|
||||
WRANGLER_TOML_CONTENT: ${{ secrets.BACKEND_TOML }}
|
||||
10
.github/workflows/docs_deploy.yml
vendored
10
.github/workflows/docs_deploy.yml
vendored
@@ -15,20 +15,20 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: check github release done
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
|
||||
20
.github/workflows/frontend_deploy.yaml
vendored
20
.github/workflows/frontend_deploy.yaml
vendored
@@ -14,17 +14,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
@@ -51,17 +51,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Telegram Frontend for ${{ github.ref_name }}
|
||||
|
||||
@@ -32,17 +32,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
|
||||
10
.github/workflows/smtp_proxy_server.yml
vendored
10
.github/workflows/smtp_proxy_server.yml
vendored
@@ -21,26 +21,26 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set lowercase repository name
|
||||
run: echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./smtp_proxy_server
|
||||
file: ./smtp_proxy_server/dockerfile
|
||||
|
||||
2
.github/workflows/sync.yaml
vendored
2
.github/workflows/sync.yaml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
|
||||
30
.github/workflows/tag_build.yml
vendored
30
.github/workflows/tag_build.yml
vendored
@@ -10,17 +10,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Build Frontend
|
||||
@@ -39,17 +39,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Build Telegram Frontend
|
||||
@@ -68,17 +68,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: cp wrangler.toml
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -6,7 +6,33 @@
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
</p>
|
||||
|
||||
## v1.4.0(main)
|
||||
## v1.5.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
||||
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
||||
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB),多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
|
||||
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900)
|
||||
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769)
|
||||
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
|
||||
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -6,7 +6,33 @@
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
</p>
|
||||
|
||||
## v1.4.0(main)
|
||||
## v1.5.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
|
||||
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
|
||||
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Auto Reply| Fix auto-reply not triggering when `source_prefix` is empty string (#459), empty value now correctly matches all senders
|
||||
- fix: |OAuth2| Fix OAuth2 login callback failure on Android via browser and other mobile browsers due to sessionStorage loss during redirect, add localStorage fallback (#900)
|
||||
- fix: |IMAP| Fix nested reply email mojibake, Gmail empty Content-Type header parsing failure, missing Date header, and locale-dependent date formatting issues
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
|
||||
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
|
||||
- feat: |Webhook| Add Telegram Bot, WeChat Work, Discord preset templates to frontend webhook settings
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
58
CLAUDE.md
58
CLAUDE.md
@@ -11,6 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **SMTP/IMAP proxy**: `smtp_proxy_server/` — Python proxy server.
|
||||
- **DB schema/migrations**: `db/` — SQLite via Cloudflare D1, dated migration patches.
|
||||
- **Docs**: `vitepress-docs/` — VitePress documentation site (zh + en).
|
||||
- **E2E tests**: `e2e/` — Playwright tests in Docker Compose (API, browser, SMTP proxy).
|
||||
- **Changelogs**: `CHANGELOG.md` (中文) + `CHANGELOG_EN.md` (English).
|
||||
|
||||
## Build & Dev Commands
|
||||
@@ -26,19 +27,58 @@ Run inside each subfolder with `pnpm`:
|
||||
|
||||
SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
Tests run in Docker Compose with Playwright. From `e2e/`:
|
||||
|
||||
```bash
|
||||
npm test # Build, run all tests, exit
|
||||
npm run test:down # Clean up containers
|
||||
```
|
||||
|
||||
Test categories: `tests/api/` (API tests), `tests/browser/` (UI tests with Chromium), `tests/smtp-proxy/` (SMTP/IMAP proxy tests).
|
||||
|
||||
The Docker frontend serves over **HTTPS** (self-signed cert) with Vite proxy to worker — required for WebAuthn (`navigator.credentials`) and `crypto.subtle` which need a secure context. Browser tests use `ignoreHTTPSErrors: true`.
|
||||
|
||||
Key patterns for browser tests:
|
||||
- Frontend hashes passwords with SHA-256 (`crypto.subtle`) before sending — API test registration must use pre-hashed passwords if UI login is needed.
|
||||
- VueUse `useStorage('key', '')` with string default uses **raw string** serialization — set localStorage with raw value, not `JSON.stringify()`.
|
||||
- WebAuthn browser tests use CDP virtual authenticator (`WebAuthn.enable` + `WebAuthn.addVirtualAuthenticator`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Worker Auth Flow (`worker/src/worker.ts`)
|
||||
|
||||
Three auth layers applied via Hono middleware, each using different headers:
|
||||
|
||||
| Path prefix | Header | Purpose |
|
||||
|-------------|--------|---------|
|
||||
| `/api/*` | `Authorization: Bearer <jwt>` | Address (mailbox) credential |
|
||||
| `/user_api/*` | `x-user-token` | User account JWT |
|
||||
| `/admin/*` | `x-admin-auth` | Admin password |
|
||||
| (any) | `x-user-access-token` | User role-based access token |
|
||||
| (any) | `x-custom-auth` | Optional global access password |
|
||||
| (any) | `x-lang` | Language preference (`en`/`zh`) |
|
||||
|
||||
Public endpoints (no auth): `/open_api/*`, `/user_api/login`, `/user_api/register`, `/user_api/passkey/authenticate_*`, `/user_api/oauth2/*`.
|
||||
|
||||
### Worker Email Flow (`worker/src/email/`)
|
||||
|
||||
Cloudflare Email Worker entry: `email()` in `worker/src/email/index.ts`. Processing pipeline:
|
||||
1. Parse raw email → check junk → check address exists
|
||||
2. Auto-reply if configured → forward if configured → webhook if enabled
|
||||
3. Store in D1 database
|
||||
|
||||
### Frontend State (`frontend/src/store/index.js`, `frontend/src/api/index.js`)
|
||||
|
||||
Global state via VueUse `useStorage` for persistence. The `api` module wraps axios with auto-attached auth headers and fingerprinting. API base URL comes from `VITE_API_BASE` env var (empty = same origin).
|
||||
|
||||
## Coding Style
|
||||
|
||||
- `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
|
||||
- Keep existing naming patterns: `*_api/` folders, `utils/`, `models/`.
|
||||
- ESM imports only (`type: module`).
|
||||
|
||||
## Auth Headers
|
||||
|
||||
- Address JWT: `x-user-token`
|
||||
- User JWT: `x-user-access-token`
|
||||
- Admin: `x-admin-auth`
|
||||
- Language: `x-lang`
|
||||
|
||||
## Commits & PRs
|
||||
|
||||
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `style:`, `refactor:`, `perf:`.
|
||||
@@ -57,10 +97,6 @@ After completing any feature, bug fix, or improvement, **always check**:
|
||||
- `api/` — API reference docs
|
||||
3. **Both languages** — docs and changelogs exist in Chinese and English; always update both.
|
||||
|
||||
## Testing
|
||||
|
||||
No formal test runner. Validate with local dev servers and key flows (login, inbox, send/receive).
|
||||
|
||||
## Config
|
||||
|
||||
- Worker settings in `worker/wrangler.toml` (see `wrangler.toml.template` for bindings).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /app/frontend
|
||||
@@ -9,14 +10,37 @@ RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falli
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
# Generate self-signed cert for HTTPS (required for WebAuthn/crypto.subtle)
|
||||
RUN openssl req -x509 -newkey rsa:2048 -keyout /tmp/key.pem -out /tmp/cert.pem \
|
||||
-days 365 -nodes -subj '/CN=frontend'
|
||||
|
||||
# Allow Docker internal hostnames (e.g. "frontend") to pass Vite's host check.
|
||||
# Wrap the original config instead of sed-patching it — survives reformats.
|
||||
# Configure HTTPS with self-signed cert for secure context (WebAuthn/crypto.subtle).
|
||||
# Proxy API paths to the worker to avoid mixed-content (HTTPS->HTTP) blocking.
|
||||
RUN mv vite.config.js vite.config.original.js && \
|
||||
echo 'import config from "./vite.config.original.js";\
|
||||
config.server = { ...config.server, allowedHosts: true };\
|
||||
import fs from "fs";\
|
||||
const workerTarget = process.env.VITE_WORKER_URL || "http://worker:8787";\
|
||||
config.server = {\
|
||||
...config.server,\
|
||||
allowedHosts: true,\
|
||||
https: {\
|
||||
key: fs.readFileSync("/tmp/key.pem"),\
|
||||
cert: fs.readFileSync("/tmp/cert.pem"),\
|
||||
},\
|
||||
proxy: {\
|
||||
"/api": { target: workerTarget, changeOrigin: true },\
|
||||
"/admin": { target: workerTarget, changeOrigin: true },\
|
||||
"/user_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/open_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/external": { target: workerTarget, changeOrigin: true },\
|
||||
"/health_check": { target: workerTarget, changeOrigin: true },\
|
||||
},\
|
||||
};\
|
||||
export default config;' > vite.config.js
|
||||
|
||||
ENV VITE_API_BASE=http://worker:8787
|
||||
# Empty VITE_API_BASE so frontend uses same-origin (proxied through Vite)
|
||||
ENV VITE_API_BASE=
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ services:
|
||||
dockerfile: e2e/Dockerfile.e2e
|
||||
environment:
|
||||
WORKER_URL: http://worker:8787
|
||||
FRONTEND_URL: http://frontend:5173
|
||||
FRONTEND_URL: https://frontend:5173
|
||||
MAILPIT_API: http://mailpit:8025/api
|
||||
SMTP_PROXY_HOST: smtp-proxy
|
||||
SMTP_PROXY_SMTP_PORT: "8025"
|
||||
|
||||
@@ -16,6 +16,7 @@ ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
|
||||
@@ -29,6 +29,8 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: FRONTEND_BASE,
|
||||
...devices['Desktop Chrome'],
|
||||
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ done
|
||||
|
||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||
echo " Frontend ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
|
||||
185
e2e/tests/api/auto-reply-trigger.spec.ts
Normal file
185
e2e/tests/api/auto-reply-trigger.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Auto Reply Trigger (#459)', () => {
|
||||
/**
|
||||
* Bug #459: source_prefix empty string causes auto-reply to never trigger.
|
||||
* The old condition `results.source_prefix && ...` short-circuits when
|
||||
* source_prefix is "" (falsy). Fix: empty source_prefix should match all.
|
||||
*/
|
||||
test('empty source_prefix triggers auto-reply for any sender', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-trigger');
|
||||
|
||||
try {
|
||||
// Configure auto-reply with empty source_prefix (match all senders)
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Auto Bot',
|
||||
subject: 'Auto Reply',
|
||||
source_prefix: '',
|
||||
message: 'Thanks for your email!',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Send a mail to the address — should trigger auto-reply
|
||||
const receiveRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'anyone@other.com',
|
||||
subject: 'Hello',
|
||||
text: 'Test message',
|
||||
});
|
||||
expect(receiveRes.success).toBe(true);
|
||||
expect(receiveRes.replyCalled).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_prefix startsWith still works (backward compat)', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-prefix');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Prefix Bot',
|
||||
subject: 'Prefix Reply',
|
||||
source_prefix: 'vip@',
|
||||
message: 'VIP auto-reply',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Matching sender — should trigger
|
||||
const matchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'vip@example.com',
|
||||
subject: 'VIP mail',
|
||||
});
|
||||
expect(matchRes.replyCalled).toBe(true);
|
||||
|
||||
// Non-matching sender — should NOT trigger
|
||||
const noMatchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'random@example.com',
|
||||
subject: 'Random mail',
|
||||
});
|
||||
expect(noMatchRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_prefix regex match', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-regex');
|
||||
|
||||
try {
|
||||
// Configure regex: match senders from example.com or example.org
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Regex Bot',
|
||||
subject: 'Regex Reply',
|
||||
source_prefix: '/@example\\.(com|org)$/',
|
||||
message: 'Regex auto-reply',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Matching sender
|
||||
const matchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@example.com',
|
||||
subject: 'Match test',
|
||||
});
|
||||
expect(matchRes.replyCalled).toBe(true);
|
||||
|
||||
// Another matching sender
|
||||
const matchRes2 = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@example.org',
|
||||
subject: 'Match test 2',
|
||||
});
|
||||
expect(matchRes2.replyCalled).toBe(true);
|
||||
|
||||
// Non-matching sender
|
||||
const noMatchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@other.com',
|
||||
subject: 'No match test',
|
||||
});
|
||||
expect(noMatchRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled auto-reply does not trigger', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-disabled');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Disabled Bot',
|
||||
subject: 'Should not reply',
|
||||
source_prefix: '',
|
||||
message: 'This should never be sent',
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const receiveRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'anyone@other.com',
|
||||
subject: 'Test disabled',
|
||||
});
|
||||
expect(receiveRes.success).toBe(true);
|
||||
expect(receiveRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Send a mail via receive_mail endpoint and return the response
|
||||
* including replyCalled field.
|
||||
*/
|
||||
async function seedTestMailWithReply(
|
||||
ctx: APIRequestContext,
|
||||
address: string,
|
||||
opts: { from?: string; subject?: string; text?: string }
|
||||
): Promise<{ success: boolean; replyCalled: boolean }> {
|
||||
const from = opts.from || 'sender@test.example.com';
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const text = opts.text || 'Hello from E2E';
|
||||
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
text,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
132
e2e/tests/api/login-endpoints.spec.ts
Normal file
132
e2e/tests/api/login-endpoints.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* SHA-256 hash matching frontend hashPassword utility.
|
||||
*/
|
||||
function hashPassword(password: string): string {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
|
||||
|
||||
test('settings returns enableGlobalTurnstileCheck as false', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enableGlobalTurnstileCheck).toBe(false);
|
||||
});
|
||||
|
||||
test.describe('/open_api/site_login', () => {
|
||||
test('returns 401 when no PASSWORDS configured', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/site_login`, {
|
||||
data: {
|
||||
password: hashPassword('any-pass'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/open_api/admin_login', () => {
|
||||
test('correct hashed password succeeds', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: hashPassword('e2e-admin-pass'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
test('wrong password returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: hashPassword('wrong-admin'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('empty password returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: '',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/open_api/credential_login', () => {
|
||||
test('valid JWT credential succeeds', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'cred-login');
|
||||
try {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: jwt,
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid JWT returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: 'invalid.jwt.token',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('empty credential returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: '',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/api/address_login with cf_token', () => {
|
||||
test('address login with empty cf_token works when turnstile disabled', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'addr-cf');
|
||||
try {
|
||||
// Set a password
|
||||
await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'addr-pass-123' },
|
||||
});
|
||||
|
||||
// Login with cf_token field present but empty
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: {
|
||||
email: address,
|
||||
password: 'addr-pass-123',
|
||||
cf_token: ''
|
||||
},
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const body = await loginRes.json();
|
||||
expect(body.jwt).toBeTruthy();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
162
e2e/tests/api/passkey.spec.ts
Normal file
162
e2e/tests/api/passkey.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const TEST_USER_EMAIL = `passkey-e2e-${Date.now()}@test.example.com`;
|
||||
const TEST_USER_PASSWORD = 'test-password-123';
|
||||
|
||||
/**
|
||||
* Enable user registration via admin API, register a user, and login to get JWT.
|
||||
*/
|
||||
async function createTestUser(request: APIRequestContext): Promise<string> {
|
||||
// Enable user registration (KV setting)
|
||||
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
},
|
||||
});
|
||||
expect(enableRes.ok()).toBe(true);
|
||||
|
||||
// Register user
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
// Login to get JWT
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
expect(jwt).toBeTruthy();
|
||||
return jwt;
|
||||
}
|
||||
|
||||
test.describe('Passkey API', () => {
|
||||
let userJwt: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
userJwt = await createTestUser(request);
|
||||
});
|
||||
|
||||
test('register_request returns valid WebAuthn options', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_request`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { domain: 'localhost' },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const options = await res.json();
|
||||
|
||||
// Verify WebAuthn registration options structure
|
||||
expect(options.rp).toBeDefined();
|
||||
expect(options.rp.id).toBe('localhost');
|
||||
expect(options.user).toBeDefined();
|
||||
expect(options.user.name).toBe(TEST_USER_EMAIL);
|
||||
expect(options.challenge).toBeTruthy();
|
||||
expect(options.pubKeyCredParams).toBeInstanceOf(Array);
|
||||
expect(options.pubKeyCredParams.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('authenticate_request returns valid WebAuthn options', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_request`, {
|
||||
data: { domain: 'localhost' },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const options = await res.json();
|
||||
|
||||
// Verify WebAuthn authentication options structure
|
||||
expect(options.challenge).toBeTruthy();
|
||||
expect(options.rpId).toBe('localhost');
|
||||
expect(options.allowCredentials).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
test('authenticate_response with invalid credential returns error', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_response`, {
|
||||
data: {
|
||||
domain: 'localhost',
|
||||
origin: 'http://localhost',
|
||||
credential: { id: 'nonexistent-passkey-id' },
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('passkey list is empty for new user', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/user_api/passkey`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const passkeys = await res.json();
|
||||
expect(passkeys).toBeInstanceOf(Array);
|
||||
expect(passkeys.length).toBe(0);
|
||||
});
|
||||
|
||||
test('passkey list remains empty without registration', async ({ request }) => {
|
||||
const listRes = await request.get(`${WORKER_URL}/user_api/passkey`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const passkeys = await listRes.json();
|
||||
expect(passkeys).toBeInstanceOf(Array);
|
||||
expect(passkeys.length).toBe(0);
|
||||
});
|
||||
|
||||
test('register_response with invalid credential returns 400', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_response`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
credential: {
|
||||
id: 'fake-id',
|
||||
rawId: 'fake-raw-id',
|
||||
type: 'public-key',
|
||||
response: {
|
||||
attestationObject: 'invalid-data',
|
||||
clientDataJSON: 'invalid-data',
|
||||
},
|
||||
},
|
||||
origin: 'http://localhost',
|
||||
passkey_name: 'test-passkey',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
// Should fail verification
|
||||
expect(res.status()).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test('rename nonexistent passkey succeeds silently', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
passkey_id: 'nonexistent-id',
|
||||
passkey_name: 'new-name',
|
||||
},
|
||||
});
|
||||
// The SQL UPDATE just affects 0 rows, still returns success
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
test('rename with invalid name returns 400', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
passkey_id: 'any-id',
|
||||
passkey_name: 'x'.repeat(256),
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('delete nonexistent passkey succeeds silently', async ({ request }) => {
|
||||
const res = await request.delete(`${WORKER_URL}/user_api/passkey/nonexistent-id`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
114
e2e/tests/browser/passkey.spec.ts
Normal file
114
e2e/tests/browser/passkey.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
import { createHash } from 'crypto';
|
||||
import { WORKER_URL, FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const TEST_USER_EMAIL = `passkey-browser-${Date.now()}@test.example.com`;
|
||||
const TEST_USER_PASSWORD = 'browser-test-pwd-123';
|
||||
|
||||
// Frontend hashes passwords with SHA-256 before sending to the API.
|
||||
// Register with the hashed password so UI login matches.
|
||||
const HASHED_PASSWORD = createHash('sha256').update(TEST_USER_PASSWORD).digest('hex');
|
||||
|
||||
test.describe('Passkey Browser Flow', () => {
|
||||
let userJwt: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const api = await apiRequest.newContext();
|
||||
try {
|
||||
// Enable user registration
|
||||
await api.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { enable: true, enableMailVerify: false },
|
||||
});
|
||||
// Register user with hashed password (matching frontend behavior)
|
||||
await api.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
|
||||
});
|
||||
// Login to get JWT for localStorage injection
|
||||
const loginRes = await api.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
|
||||
});
|
||||
const body = await loginRes.json();
|
||||
userJwt = body.jwt;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('register passkey, then login with passkey', async ({ page, context }) => {
|
||||
// Set up virtual authenticator via CDP
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('WebAuthn.enable');
|
||||
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: 'ctap2',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// === Step 1: Login via localStorage injection ===
|
||||
// Inject JWT into localStorage to skip UI login flow.
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
// VueUse's useStorage with string default stores raw strings (no JSON)
|
||||
await page.evaluate((jwt) => {
|
||||
localStorage.setItem('userJwt', jwt);
|
||||
}, userJwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
// Wait for user settings to load (shows user email)
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// === Step 2: Click "User Settings" tab ===
|
||||
await page.getByText('User Settings').click();
|
||||
|
||||
// === Step 3: Create a passkey ===
|
||||
await page.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
// Fill passkey name in the modal
|
||||
const createModal = page.locator('.n-dialog');
|
||||
await expect(createModal).toBeVisible({ timeout: 5_000 });
|
||||
await createModal.getByRole('textbox').fill('E2E Test Passkey');
|
||||
|
||||
// Click the Create Passkey button inside the modal
|
||||
await createModal.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
// Wait for success — modal should close
|
||||
await expect(createModal).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// === Step 4: Verify passkey appears in the list ===
|
||||
await page.getByRole('button', { name: 'Show Passkey List' }).click();
|
||||
|
||||
const listModal = page.locator('.n-card-header:has-text("Show Passkey List")').locator('..');
|
||||
await expect(page.getByText('E2E Test Passkey')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Close the list modal
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// === Step 5: Logout ===
|
||||
await page.getByRole('button', { name: 'Logout' }).click();
|
||||
const logoutModal = page.locator('.n-dialog');
|
||||
await expect(logoutModal).toBeVisible({ timeout: 5_000 });
|
||||
await logoutModal.getByRole('button', { name: 'Logout' }).click();
|
||||
|
||||
// Wait for logout to complete and navigate to user page
|
||||
await page.waitForTimeout(2000);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
// === Step 6: Login with passkey ===
|
||||
const passkeyBtn = page.getByRole('button', { name: 'Login with Passkey' });
|
||||
await expect(passkeyBtn).toBeVisible({ timeout: 10_000 });
|
||||
await passkeyBtn.click();
|
||||
|
||||
// Virtual authenticator handles the WebAuthn ceremony automatically
|
||||
// Wait for login to complete — user email should appear
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
||||
await cdp.detach();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -23,21 +23,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
||||
"@simplewebauthn/browser": "10.0.0",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.13.6",
|
||||
"dompurify": "^3.3.2",
|
||||
"dompurify": "^3.3.3",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.1",
|
||||
"naive-ui": "^2.43.2",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.3",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.29",
|
||||
"vue": "^3.5.30",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,7 +54,7 @@
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"wrangler": "^4.70.0"
|
||||
"wrangler": "^4.72.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
485
frontend/pnpm-lock.yaml
generated
485
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||
statusUrl: res["statusUrl"] || "",
|
||||
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
|
||||
@@ -17,17 +17,21 @@ const { locale, t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
|
||||
const cfTurnstileId = ref("")
|
||||
const turnstileLoading = ref(false)
|
||||
|
||||
const refresh = () => checkCfTurnstile(true)
|
||||
defineExpose({ refresh })
|
||||
|
||||
const checkCfTurnstile = async (remove) => {
|
||||
if (!openSettings.value.cfTurnstileSiteKey) return;
|
||||
turnstileLoading.value = true;
|
||||
try {
|
||||
let container = document.getElementById("cf-turnstile");
|
||||
let container = document.getElementById(containerId);
|
||||
let count = 100;
|
||||
while (!container && count-- > 0) {
|
||||
container = document.getElementById("cf-turnstile");
|
||||
container = document.getElementById(containerId);
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
count = 100;
|
||||
@@ -38,7 +42,7 @@ const checkCfTurnstile = async (remove) => {
|
||||
window.turnstile.remove(cfTurnstileId.value);
|
||||
}
|
||||
cfTurnstileId.value = window.turnstile.render(
|
||||
"#cf-turnstile",
|
||||
`#${containerId}`,
|
||||
{
|
||||
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||
@@ -68,7 +72,7 @@ onMounted(() => {
|
||||
<n-spin description="loading..." :show="turnstileLoading">
|
||||
<n-form-item-row>
|
||||
<n-flex vertical>
|
||||
<div id="cf-turnstile"></div>
|
||||
<div :id="containerId"></div>
|
||||
<n-button text @click="checkCfTurnstile(true)">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
|
||||
@@ -117,6 +117,55 @@ const presets: WebhookPreset[] = [
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Telegram Bot',
|
||||
doc: 'https://core.telegram.org/bots/api#sendmessage',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"chat_id": "YOUR_CHAT_ID",
|
||||
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WeChat Work',
|
||||
doc: 'https://developer.work.weixin.qq.com/document/path/91770',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
doc: 'https://discord.com/developers/docs/resources/webhook',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({
|
||||
|
||||
@@ -39,6 +39,7 @@ export const useGlobalState = createGlobalState(
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
statusUrl: '',
|
||||
enableGlobalTurnstileCheck: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -111,8 +112,18 @@ export const useGlobalState = createGlobalState(
|
||||
);
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
|
||||
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
|
||||
const _oauth2StateSession = useSessionStorage('userOauth2SessionState', '');
|
||||
const _oauth2StateFallback = useStorage('userOauth2SessionState_fb', '');
|
||||
const userOauth2SessionState = computed({
|
||||
get: () => _oauth2StateSession.value || _oauth2StateFallback.value,
|
||||
set: (v) => { _oauth2StateSession.value = v; _oauth2StateFallback.value = v; }
|
||||
});
|
||||
const _oauth2ClientIDSession = useSessionStorage('userOauth2SessionClientID', '');
|
||||
const _oauth2ClientIDFallback = useStorage('userOauth2SessionClientID_fb', '');
|
||||
const userOauth2SessionClientID = computed({
|
||||
get: () => _oauth2ClientIDSession.value || _oauth2ClientIDFallback.value,
|
||||
set: (v) => { _oauth2ClientIDSession.value = v; _oauth2ClientIDFallback.value = v; }
|
||||
});
|
||||
const browserFingerprint = ref('');
|
||||
return {
|
||||
isDark,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../utils'
|
||||
import Turnstile from '../components/Turnstile.vue'
|
||||
|
||||
import SenderAccess from './admin/SenderAccess.vue'
|
||||
import Statistics from "./admin/Statistics.vue"
|
||||
@@ -44,12 +45,23 @@ const SendMail = defineAsyncComponent(() => {
|
||||
.finally(() => loading.value = false);
|
||||
});
|
||||
|
||||
const cfToken = ref('')
|
||||
const turnstileRef = ref(null)
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
await api.fetch('/open_api/admin_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(tmpAdminAuth.value),
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
adminAuth.value = tmpAdminAuth.value;
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
turnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +181,8 @@ const currentLoginMethod = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure openSettings is fetched for turnstile check
|
||||
if (!openSettings.value.fetched) await api.getOpenSettings(message);
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
})
|
||||
@@ -180,6 +194,7 @@ onMounted(async () => {
|
||||
preset="dialog" :title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" type="primary" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -12,7 +12,8 @@ import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../utils'
|
||||
import Turnstile from '../components/Turnstile.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
@@ -32,11 +33,22 @@ const menuValue = computed(() => {
|
||||
return "home";
|
||||
});
|
||||
|
||||
const cfToken = ref('')
|
||||
const turnstileRef = ref(null)
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
await api.fetch('/open_api/site_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(auth.value),
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
turnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +299,7 @@ onMounted(async () => {
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="auth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -21,7 +21,7 @@ const { t } = useI18n({
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
maxAddressCount: 'Maximum number of email addresses that can be binded',
|
||||
emailCheckRegex: 'Email Check Regex (e.g. ^[^.]+@.+$ to disallow dots before @)',
|
||||
emailCheckRegex: "Email Check Regex (e.g. ^[^.]+{'@'}.+$ to disallow dots before {'@'})",
|
||||
enableEmailCheckRegex: 'Enable Email Check Regex',
|
||||
},
|
||||
zh: {
|
||||
@@ -35,7 +35,7 @@ const { t } = useI18n({
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量',
|
||||
emailCheckRegex: '邮箱正则校验 (例如 ^[^.]+@.+$ 禁止@前面有.)',
|
||||
emailCheckRegex: "邮箱正则校验 (例如 ^[^.]+{'@'}.+$ 禁止{'@'}前面有.)",
|
||||
enableEmailCheckRegex: '启用邮箱正则校验',
|
||||
}
|
||||
}
|
||||
@@ -132,14 +132,14 @@ onMounted(async () => {
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableEmailCheckRegex')">
|
||||
<n-input-group>
|
||||
<n-checkbox v-model:checked="userSettings.enableEmailCheckRegex" style="width: 20%;">
|
||||
<n-flex align="center" :wrap="false" style="width: 100%;">
|
||||
<n-checkbox v-model:checked="userSettings.enableEmailCheckRegex" style="flex: 0 0 auto;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-input v-model:value="userSettings.emailCheckRegex"
|
||||
v-if="userSettings.enableEmailCheckRegex"
|
||||
style="width: 80%;" :placeholder="t('emailCheckRegex')" />
|
||||
</n-input-group>
|
||||
v-show="userSettings.enableEmailCheckRegex"
|
||||
style="flex: 1 1 auto;" :placeholder="t('emailCheckRegex')" />
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
@@ -47,6 +47,8 @@ const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const loginCfToken = ref("")
|
||||
const loginTurnstileRef = ref(null)
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
const loginAddress = ref('')
|
||||
const loginPassword = ref('')
|
||||
@@ -72,7 +74,8 @@ const login = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: loginAddress.value,
|
||||
password: await hashPassword(loginPassword.value)
|
||||
password: await hashPassword(loginPassword.value),
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
jwt.value = res.jwt;
|
||||
@@ -85,6 +88,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -93,6 +97,13 @@ const login = async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch('/open_api/credential_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credential: credential.value,
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
jwt.value = credential.value;
|
||||
await api.getSettings();
|
||||
try {
|
||||
@@ -103,6 +114,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +301,9 @@ onMounted(async () => {
|
||||
</n-form-item-row>
|
||||
</div>
|
||||
|
||||
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck"
|
||||
v-model:value="loginCfToken" />
|
||||
|
||||
<div class="switch-login-button">
|
||||
<n-button v-if="openSettings?.enableAddressPassword"
|
||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||
|
||||
@@ -21,7 +21,8 @@ const { t } = useI18n({
|
||||
en: {
|
||||
success: 'Success',
|
||||
settings: 'Settings',
|
||||
sourcePrefix: 'Source Mail Prefix',
|
||||
sourcePrefix: 'Sender Filter',
|
||||
sourcePrefixPlaceholder: 'Empty=all, prefix match, or /regex/',
|
||||
name: 'Name',
|
||||
enableAutoReply: 'Enable Auto Reply',
|
||||
subject: 'Subject',
|
||||
@@ -31,7 +32,8 @@ const { t } = useI18n({
|
||||
zh: {
|
||||
success: '成功',
|
||||
settings: '设置',
|
||||
sourcePrefix: '来源邮件前缀',
|
||||
sourcePrefix: '发件人过滤',
|
||||
sourcePrefixPlaceholder: '留空=全部匹配,前缀匹配,或 /正则/',
|
||||
name: '名称',
|
||||
enableAutoReply: '启用自动回复',
|
||||
subject: '主题',
|
||||
@@ -93,7 +95,8 @@ onMounted(async () => {
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="name" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('sourcePrefix')" label-placement="left">
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="sourcePrefix" />
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="sourcePrefix"
|
||||
:placeholder="t('sourcePrefixPlaceholder')" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="left">
|
||||
<n-input :disabled="!enableAutoReply" v-model:value="subject" />
|
||||
|
||||
@@ -69,7 +69,12 @@ const user = ref({
|
||||
password: "",
|
||||
code: ""
|
||||
});
|
||||
const cfToken = ref("")
|
||||
const signupCfToken = ref("")
|
||||
const resetCfToken = ref("")
|
||||
const loginCfToken = ref("")
|
||||
const signupTurnstileRef = ref(null)
|
||||
const resetTurnstileRef = ref(null)
|
||||
const loginTurnstileRef = ref(null)
|
||||
|
||||
const emailLogin = async () => {
|
||||
if (!user.value.email || !user.value.password) {
|
||||
@@ -82,13 +87,15 @@ const emailLogin = async () => {
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password)
|
||||
password: await hashPassword(user.value.password),
|
||||
cf_token: loginCfToken.value
|
||||
})
|
||||
});
|
||||
userJwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
message.error(error.message || "login failed");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,7 +112,8 @@ const sendVerificationCode = async () => {
|
||||
message.error(t('pleaseInputEmail'));
|
||||
return;
|
||||
}
|
||||
if (openSettings.value.cfTurnstileSiteKey && !cfToken.value && userOpenSettings.value.enableMailVerify) {
|
||||
const currentCfToken = showModal.value ? resetCfToken.value : signupCfToken.value;
|
||||
if (openSettings.value.cfTurnstileSiteKey && !currentCfToken && userOpenSettings.value.enableMailVerify) {
|
||||
message.error(t('pleaseCompleteTurnstile'));
|
||||
return;
|
||||
}
|
||||
@@ -114,7 +122,7 @@ const sendVerificationCode = async () => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: user.value.email,
|
||||
cf_token: cfToken.value
|
||||
cf_token: currentCfToken
|
||||
})
|
||||
});
|
||||
if (res && res.expirationTtl) {
|
||||
@@ -131,6 +139,11 @@ const sendVerificationCode = async () => {
|
||||
} catch (error) {
|
||||
message.error(error.message || "send verification code failed");
|
||||
}
|
||||
if (showModal.value) {
|
||||
resetTurnstileRef.value?.refresh?.();
|
||||
} else {
|
||||
signupTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
};
|
||||
|
||||
const emailSignup = async () => {
|
||||
@@ -149,7 +162,8 @@ const emailSignup = async () => {
|
||||
email: user.value.email,
|
||||
// hash password
|
||||
password: await hashPassword(user.value.password),
|
||||
code: user.value.code
|
||||
code: user.value.code,
|
||||
cf_token: showModal.value ? resetCfToken.value : signupCfToken.value
|
||||
}),
|
||||
message: message
|
||||
});
|
||||
@@ -171,7 +185,7 @@ const passkeyLogin = async () => {
|
||||
domain: location.hostname,
|
||||
})
|
||||
})
|
||||
const credential = await startAuthentication(options)
|
||||
const credential = await startAuthentication({ optionsJSON: options })
|
||||
|
||||
// Send the result to the server and return the promise.
|
||||
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
|
||||
@@ -218,6 +232,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
|
||||
<n-button @click="emailLogin" type="primary" block secondary strong>
|
||||
{{ t('login') }}
|
||||
</n-button>
|
||||
@@ -248,7 +263,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
|
||||
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
|
||||
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
@@ -259,6 +274,7 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<Turnstile ref="signupTurnstileRef" v-if="!userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
|
||||
</n-form>
|
||||
<n-button @click="emailSignup" type="primary" block secondary strong>
|
||||
{{ t('register') }}
|
||||
@@ -273,7 +289,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('password')" required>
|
||||
<n-input v-model:value="user.password" type="password" show-password-on="click" />
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
|
||||
<n-form-item-row :label="t('verifyCode')" required>
|
||||
<n-input-group>
|
||||
<n-input v-model:value="user.code" />
|
||||
|
||||
@@ -19,28 +19,30 @@ const { t } = useI18n({
|
||||
en: {
|
||||
logging: 'Logging in...',
|
||||
stateNotMatch: 'state not match',
|
||||
codeNotFound: 'code not found',
|
||||
},
|
||||
zh: {
|
||||
logging: '登录中...',
|
||||
stateNotMatch: 'state 不匹配',
|
||||
codeNotFound: '未找到授权码',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const state = route.query.state;
|
||||
if (state != userOauth2SessionState.value) {
|
||||
console.error('state not match');
|
||||
message.error(t('stateNotMatch'));
|
||||
return;
|
||||
}
|
||||
const code = route.query.code;
|
||||
if (!code) {
|
||||
console.error('code not found');
|
||||
message.error('code not found');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = route.query.state;
|
||||
if (state != userOauth2SessionState.value) {
|
||||
console.error('state not match');
|
||||
message.error(t('stateNotMatch'));
|
||||
return;
|
||||
}
|
||||
const code = route.query.code;
|
||||
if (!code) {
|
||||
console.error('code not found');
|
||||
message.error(t('codeNotFound'));
|
||||
return;
|
||||
}
|
||||
const res = await api.fetch(`/user_api/oauth2/callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -53,6 +55,9 @@ onMounted(async () => {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || 'error');
|
||||
} finally {
|
||||
userOauth2SessionState.value = '';
|
||||
userOauth2SessionClientID.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -71,7 +71,7 @@ const createPasskey = async () => {
|
||||
domain: location.hostname,
|
||||
})
|
||||
})
|
||||
const credential = await startRegistration(options)
|
||||
const credential = await startRegistration({ optionsJSON: options })
|
||||
|
||||
// Send the result to the server and return the promise.
|
||||
await api.fetch(`/user_api/passkey/register_response`, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mail-parser-wasm"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
description = "A simple mail parser for wasm"
|
||||
license = "MIT"
|
||||
|
||||
@@ -101,38 +101,29 @@ impl MessageResult {
|
||||
pub fn parse_attachment(message: &mail_parser::Message) -> Vec<AttachmentResult> {
|
||||
let mut attachments: Vec<AttachmentResult> = Vec::new();
|
||||
for attachment in message.attachments() {
|
||||
if !attachment.is_message() {
|
||||
attachments.push(AttachmentResult {
|
||||
content_id: attachment
|
||||
.content_id()
|
||||
.map(|id| id.to_owned())
|
||||
.unwrap_or(String::new()),
|
||||
content_type: attachment
|
||||
.content_type()
|
||||
.map(|ct| {
|
||||
let c_type = ct.c_type.clone().into_owned();
|
||||
let c_subtype = ct.c_subtype.clone();
|
||||
if c_subtype.is_none() {
|
||||
return c_type;
|
||||
} else {
|
||||
return format!("{}/{}", c_type, c_subtype.unwrap());
|
||||
}
|
||||
})
|
||||
.unwrap_or(String::new()),
|
||||
filename: attachment
|
||||
.attachment_name()
|
||||
.map(|name| name.to_owned())
|
||||
.unwrap_or(String::new()),
|
||||
content: attachment.contents().to_vec(),
|
||||
});
|
||||
} else {
|
||||
attachments.append(
|
||||
&mut attachment
|
||||
.message()
|
||||
.map(|msg| parse_attachment(msg))
|
||||
.unwrap_or(Vec::new()),
|
||||
);
|
||||
}
|
||||
attachments.push(AttachmentResult {
|
||||
content_id: attachment
|
||||
.content_id()
|
||||
.map(|id| id.to_owned())
|
||||
.unwrap_or(String::new()),
|
||||
content_type: attachment
|
||||
.content_type()
|
||||
.map(|ct| {
|
||||
let c_type = ct.c_type.clone().into_owned();
|
||||
let c_subtype = ct.c_subtype.clone();
|
||||
if c_subtype.is_none() {
|
||||
return c_type;
|
||||
} else {
|
||||
return format!("{}/{}", c_type, c_subtype.unwrap());
|
||||
}
|
||||
})
|
||||
.unwrap_or(String::new()),
|
||||
filename: attachment
|
||||
.attachment_name()
|
||||
.map(|name| name.to_owned())
|
||||
.unwrap_or(String::new()),
|
||||
content: attachment.contents().to_vec(),
|
||||
});
|
||||
}
|
||||
attachments
|
||||
}
|
||||
|
||||
5
mail-parser-wasm/worker/.gitignore
vendored
Normal file
5
mail-parser-wasm/worker/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
mail_parser_wasm_bg.wasm
|
||||
mail_parser_wasm_bg.wasm.d.ts
|
||||
mail_parser_wasm.js
|
||||
mail_parser_wasm.d.ts
|
||||
README.md
|
||||
@@ -7,7 +7,7 @@
|
||||
"url": "https://github.com/dreamhunter2333/cloudflare_temp_email",
|
||||
"directory": "mail-parser-wasm"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"mail_parser_wasm_bg.wasm",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.70.0"
|
||||
"wrangler": "^4.72.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class BackendClient:
|
||||
"""
|
||||
|
||||
def __init__(self, password: str):
|
||||
self.password = password
|
||||
self.password = password.strip()
|
||||
self._client = httpx.Client(
|
||||
base_url=settings.proxy_url,
|
||||
headers={
|
||||
|
||||
@@ -10,7 +10,7 @@ from zope.interface import implementer
|
||||
from config import settings
|
||||
from imap_http_client import BackendClient
|
||||
from imap_message import SimpleMessage
|
||||
from parse_email import generate_email_model, parse_email
|
||||
from parse_email import generate_email_model, parse_email, clean_raw_headers, fix_mojibake
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
@@ -246,6 +246,8 @@ class SimpleMailbox:
|
||||
try:
|
||||
if self.name == "INBOX":
|
||||
raw = item.get("raw", "")
|
||||
raw = fix_mojibake(raw)
|
||||
raw = clean_raw_headers(raw)
|
||||
email_model = parse_email(raw)
|
||||
elif self.name == "SENT":
|
||||
email_model, raw = generate_email_model(item)
|
||||
@@ -256,7 +258,8 @@ class SimpleMailbox:
|
||||
self._flags[uid_val] = {r"\Seen"}
|
||||
flags = self._flags[uid_val]
|
||||
msg = SimpleMessage(
|
||||
uid_val, email_model, flags=flags, raw=raw
|
||||
uid_val, email_model, flags=flags, raw=raw,
|
||||
created_at=item.get("created_at"),
|
||||
)
|
||||
self._cache.put(uid_val, msg)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,28 +1,72 @@
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from twisted.mail import imap4
|
||||
from zope.interface import implementer
|
||||
|
||||
from models import EmailModel
|
||||
|
||||
# Locale-independent English names for IMAP date formatting
|
||||
_MONTHS = ('', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
|
||||
_DAYS = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
|
||||
|
||||
_CREATED_AT_FMTS = (
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ",
|
||||
"%Y-%m-%d %H:%M:%S.%f",
|
||||
)
|
||||
|
||||
|
||||
def parse_created_at(created_at: str) -> datetime | None:
|
||||
"""Parse created_at string into datetime, returns None on failure."""
|
||||
for fmt in _CREATED_AT_FMTS:
|
||||
try:
|
||||
return datetime.strptime(created_at, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def format_imap_date(dt: datetime) -> str:
|
||||
"""Format datetime as IMAP INTERNALDATE: '21-Mar-2026 13:04:59 +0000'."""
|
||||
return (f"{dt.day:02d}-{_MONTHS[dt.month]}-{dt.year} "
|
||||
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
|
||||
|
||||
|
||||
def format_rfc2822_date(dt: datetime) -> str:
|
||||
"""Format datetime as RFC 2822: 'Thu, 13 Mar 2026 11:15:57 +0000'."""
|
||||
return (f"{_DAYS[dt.weekday()]}, {dt.day:02d} {_MONTHS[dt.month]} {dt.year} "
|
||||
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
|
||||
|
||||
|
||||
@implementer(imap4.IMessage, imap4.IMessageFile)
|
||||
class SimpleMessage:
|
||||
|
||||
def __init__(self, uid: int, email_model: EmailModel,
|
||||
flags: set[str] = None, raw: str = None):
|
||||
flags: set[str] = None, raw: str = None, created_at: str = None):
|
||||
self.uid = uid
|
||||
self.email = email_model
|
||||
self.subparts = self.email.subparts
|
||||
self._flags = flags if flags is not None else set()
|
||||
self._raw = raw
|
||||
self._created_at = created_at
|
||||
self._fill_date_header()
|
||||
|
||||
def _fill_date_header(self):
|
||||
"""Fill empty/missing Date header from created_at."""
|
||||
date_val = self.email.headers.get("Date", "").strip()
|
||||
if date_val or not self._created_at:
|
||||
return
|
||||
dt = parse_created_at(self._created_at)
|
||||
if dt:
|
||||
self.email.headers["Date"] = format_rfc2822_date(dt)
|
||||
|
||||
def getUID(self):
|
||||
return self.uid
|
||||
|
||||
def getHeaders(self, negate, *names):
|
||||
# Twisted passes header names as bytes (e.g. b"SUBJECT");
|
||||
# normalize to lowercase str for comparison.
|
||||
names_lower = set()
|
||||
for n in names:
|
||||
if isinstance(n, bytes):
|
||||
@@ -47,6 +91,10 @@ class SimpleMessage:
|
||||
return len(self.subparts) > 0
|
||||
|
||||
def getSubPart(self, part):
|
||||
if not self.subparts:
|
||||
if part == 0:
|
||||
return SimpleMessage(self.uid, self.email, flags=self._flags)
|
||||
raise IndexError(part)
|
||||
return SimpleMessage(self.uid, self.subparts[part], flags=self._flags)
|
||||
|
||||
def getBodyFile(self):
|
||||
@@ -61,6 +109,10 @@ class SimpleMessage:
|
||||
return list(self._flags)
|
||||
|
||||
def getInternalDate(self):
|
||||
if self._created_at:
|
||||
dt = parse_created_at(self._created_at)
|
||||
if dt:
|
||||
return format_imap_date(dt)
|
||||
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
|
||||
|
||||
# IMessageFile
|
||||
|
||||
@@ -81,13 +81,18 @@ class Account(imap4.MemoryAccount):
|
||||
"""Custom account that initializes mailbox UID index on select."""
|
||||
|
||||
def _emptyMailbox(self, name, id):
|
||||
"""Ignore CREATE for unknown mailboxes instead of crashing."""
|
||||
return None
|
||||
"""Return a dummy mailbox for CREATE requests (e.g. Gmail creating Drafts)."""
|
||||
_logger.debug("Accepting CREATE request for %s", name)
|
||||
return SimpleMailbox(name, self._client)
|
||||
|
||||
def create(self, pathspec):
|
||||
"""Silently ignore mailbox creation requests from clients."""
|
||||
_logger.debug("Ignoring CREATE request for %s", pathspec)
|
||||
return False
|
||||
"""Accept CREATE silently without actually creating mailboxes."""
|
||||
_logger.debug("Ignoring CREATE for %s", pathspec)
|
||||
return True
|
||||
|
||||
def listMailboxes(self, ref, wildcard):
|
||||
"""Only list INBOX and SENT, ignore client-created mailboxes."""
|
||||
return [("INBOX", self.mailboxes["INBOX"]), ("SENT", self.mailboxes["SENT"])]
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def select(self, name, readwrite=1):
|
||||
@@ -110,6 +115,7 @@ class SimpleRealm:
|
||||
sent = SimpleMailbox("SENT", client)
|
||||
|
||||
account = Account(username)
|
||||
account._client = client
|
||||
account.mailboxes = {"INBOX": inbox, "SENT": sent}
|
||||
account.subscriptions = ["INBOX", "SENT"]
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import email
|
||||
|
||||
from email.message import Message
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
|
||||
from models import EmailModel
|
||||
from imap_message import parse_created_at, format_rfc2822_date
|
||||
|
||||
import re
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
# Matches an empty header value (header name with no value)
|
||||
_EMPTY_HEADER_RE = re.compile(r'^([A-Za-z][A-Za-z0-9-]*):\s*\r?\n', re.MULTILINE)
|
||||
|
||||
|
||||
def get_email_model(msg: Message):
|
||||
subparts = [
|
||||
@@ -22,23 +26,62 @@ def get_email_model(msg: Message):
|
||||
if msg.is_multipart():
|
||||
body = ""
|
||||
else:
|
||||
raw_body = msg.get_payload(decode=True) or b""
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
try:
|
||||
body = raw_body.decode(charset, errors="replace")
|
||||
except LookupError:
|
||||
body = raw_body.decode("utf-8", errors="replace")
|
||||
# Keep body in its original CTE encoding (base64/QP/7bit/8bit)
|
||||
# so it matches the Content-Transfer-Encoding header.
|
||||
# The IMAP client will decode CTE itself based on BODYSTRUCTURE.
|
||||
body = msg.get_payload(decode=False) or ""
|
||||
return EmailModel(
|
||||
headers={k: v for k, v in msg.items()},
|
||||
body=body,
|
||||
content_type=msg.get_content_type(),
|
||||
size=len(body) + sum(subpart.size for subpart in subparts),
|
||||
size=len(body.encode("utf-8") if isinstance(body, str) else body) + sum(subpart.size for subpart in subparts),
|
||||
subparts=subparts,
|
||||
)
|
||||
|
||||
|
||||
def clean_raw_headers(raw: str) -> str:
|
||||
"""Remove empty header lines that break Python email parser.
|
||||
|
||||
Some emails (e.g. from Gmail via Cloudflare) have duplicate headers
|
||||
like 'Content-Type: \\n' (empty) followed by the real Content-Type.
|
||||
The empty one confuses email.message_from_string().
|
||||
|
||||
Applies globally so nested message/rfc822 parts are also cleaned.
|
||||
"""
|
||||
return _EMPTY_HEADER_RE.sub('', raw)
|
||||
|
||||
|
||||
def fix_mojibake(raw: str) -> str:
|
||||
"""Fix UTF-8 mojibake where upstream stored UTF-8 bytes as cp1252/latin-1.
|
||||
|
||||
Tries whole-string fix first (fast path). If that fails (e.g. complex
|
||||
emails with mixed binary/text content), falls back to line-by-line fix.
|
||||
"""
|
||||
# Fast path: fix entire string at once
|
||||
for enc in ("cp1252", "latin-1"):
|
||||
try:
|
||||
return raw.encode(enc).decode("utf-8")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
continue
|
||||
|
||||
# Slow path: fix line by line (tolerates mixed content)
|
||||
lines = raw.split('\n')
|
||||
fixed = []
|
||||
for line in lines:
|
||||
fixed_line = line
|
||||
for enc in ("cp1252", "latin-1"):
|
||||
try:
|
||||
fixed_line = line.encode(enc).decode("utf-8")
|
||||
break
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
continue
|
||||
fixed.append(fixed_line)
|
||||
return '\n'.join(fixed)
|
||||
|
||||
|
||||
def parse_email(raw: str) -> EmailModel:
|
||||
try:
|
||||
raw = clean_raw_headers(raw)
|
||||
msg = email.message_from_string(raw)
|
||||
return get_email_model(msg)
|
||||
except Exception as e:
|
||||
@@ -52,6 +95,7 @@ def parse_email(raw: str) -> EmailModel:
|
||||
)
|
||||
|
||||
|
||||
|
||||
def generate_email_model(item: dict) -> tuple[EmailModel, str]:
|
||||
"""Build an EmailModel from a sendbox item.
|
||||
|
||||
@@ -59,25 +103,24 @@ def generate_email_model(item: dict) -> tuple[EmailModel, str]:
|
||||
synthesised MIME to SimpleMessage for correct BODY[] responses.
|
||||
"""
|
||||
email_json = json.loads(item["raw"])
|
||||
message = MIMEMultipart()
|
||||
if email_json.get("version") == "v2":
|
||||
message['From'] = f'{email_json["from_name"]} <{item["address"]}>' if email_json.get("from_name") else item["address"]
|
||||
message['To'] = f'{email_json["to_name"]} <{email_json["to_mail"]}>' if email_json.get("to_name") else email_json["to_mail"]
|
||||
message.attach(MIMEText(
|
||||
email_json["content"],
|
||||
"html" if email_json.get("is_html") else "plain"
|
||||
))
|
||||
from_addr = f'{email_json["from_name"]} <{item["address"]}>' if email_json.get("from_name") else item["address"]
|
||||
to_addr = f'{email_json["to_name"]} <{email_json["to_mail"]}>' if email_json.get("to_name") else email_json["to_mail"]
|
||||
content = email_json["content"]
|
||||
subtype = "html" if email_json.get("is_html") else "plain"
|
||||
else:
|
||||
message['From'] = f'{email_json["from"]["name"]} <{email_json["from"]["email"]}>'
|
||||
message['To'] = ", ".join(
|
||||
from_addr = f'{email_json["from"]["name"]} <{email_json["from"]["email"]}>'
|
||||
to_addr = ", ".join(
|
||||
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
|
||||
message.attach(MIMEText(
|
||||
email_json["content"][0]["value"],
|
||||
"html" if "html" in email_json["content"][0]["type"] else "plain"
|
||||
))
|
||||
content = email_json["content"][0]["value"]
|
||||
subtype = "html" if "html" in email_json["content"][0]["type"] else "plain"
|
||||
|
||||
message = MIMEText(content, subtype, "utf-8")
|
||||
message['From'] = from_addr
|
||||
message['To'] = to_addr
|
||||
message['Subject'] = email_json["subject"]
|
||||
message["Date"] = datetime.datetime.strptime(
|
||||
item["created_at"], "%Y-%m-%d %H:%M:%S"
|
||||
).strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
dt = parse_created_at(item["created_at"])
|
||||
if dt:
|
||||
message["Date"] = format_rfc2822_date(dt)
|
||||
raw_mime = message.as_string()
|
||||
return parse_email(raw_mime), raw_mime
|
||||
|
||||
@@ -2,5 +2,5 @@ aiosmtpd==1.4.6
|
||||
pydantic-settings==2.13.1
|
||||
Twisted==25.5.0
|
||||
httpx==0.28.1
|
||||
pyOpenSSL==25.3.0
|
||||
pyOpenSSL==26.0.0
|
||||
service-identity==24.2.0
|
||||
|
||||
@@ -17,6 +17,8 @@ res = requests.get(
|
||||
)
|
||||
```
|
||||
|
||||
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
|
||||
|
||||
## Admin Mail API
|
||||
|
||||
Supports `address` filter
|
||||
@@ -43,6 +45,8 @@ response = requests.get(url, headers=headers, params=querystring)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
**Note**: `/admin/mails` follows the same design as `/api/mails`: it returns stored raw MIME data. If you need readable subject/body, parse the raw content on the client side.
|
||||
|
||||
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
|
||||
|
||||
## Admin Delete Mail API
|
||||
@@ -151,4 +155,6 @@ response = requests.get(url, headers=headers, params=querystring)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
**Note**: `/user_api/mails` also returns raw RFC822 content from storage; parse it in your client to extract `subject`, `text`, and `html`.
|
||||
|
||||
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
|
||||
|
||||
@@ -55,6 +55,42 @@ You need to configure `TG_ALLOW_USER_LANG = true` in worker variables to enable
|
||||
|
||||
Language preferences are saved to KV, and each user can set their preference independently.
|
||||
|
||||
## Per-User Mail Push
|
||||
|
||||
Telegram Bot supports **per-user push notifications**. After a user binds an address, emails received at that address are automatically pushed to the corresponding user.
|
||||
|
||||
### User Workflow
|
||||
|
||||
1. Find your deployed Bot in Telegram
|
||||
2. Use `/new [name@domain]` to create a new address, or `/bind <credential>` to bind an existing address
|
||||
3. Once bound, you will **automatically receive push notifications** when the address receives mail
|
||||
4. Use `/address` to view your bound addresses
|
||||
5. Use `/unbind <address>` to unbind an address
|
||||
|
||||
> [!TIP]
|
||||
> Each user can bind up to `TG_MAX_ADDRESS` (default 5) addresses
|
||||
|
||||
### Global Push
|
||||
|
||||
Admins can enable **global mail push** in the admin panel under `Settings` -> `Telegram`, pushing all emails to a specified list of Telegram user IDs.
|
||||
|
||||
- `enableGlobalMailPush`: Enable global push
|
||||
- `globalMailPushList`: List of Telegram user IDs to receive global push
|
||||
|
||||
> [!NOTE]
|
||||
> Global push and per-user push can work simultaneously. If an address is bound to a user who is also in the global push list, they will receive two notifications.
|
||||
|
||||
### Attachment Push
|
||||
|
||||
> [!NOTE]
|
||||
> This feature is available since v1.5.0
|
||||
|
||||
Set `ENABLE_TG_PUSH_ATTACHMENT = true` to enable sending email attachments via Telegram push.
|
||||
|
||||
- Single file size limit is 50MB (Telegram Bot API limit), oversized attachments are skipped
|
||||
- Multiple attachments are sent in batches via `sendMediaGroup`, up to 6 per batch
|
||||
- The first attachment includes the sender and subject as caption
|
||||
|
||||
## Mini App
|
||||
|
||||
Can be deployed via command line or UI interface
|
||||
|
||||
@@ -26,6 +26,77 @@ This project uses [songquanpeng/message-pusher](https://github.com/songquanpeng/
|
||||
|
||||

|
||||
|
||||
## Webhook Template Examples
|
||||
|
||||
### Telegram Bot Push
|
||||
|
||||
Push email notifications by calling the Telegram Bot API directly via webhook. Suitable for scenarios where you don't want to deploy the full Telegram Bot integration or need a custom push format.
|
||||
|
||||
- **URL**: `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
- **Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_id": "YOUR_CHAT_ID",
|
||||
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> To get your `chat_id`: send a message to the Bot, then visit `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates` and look for the `chat.id` field in the response
|
||||
|
||||
### WeChat Work Bot Push
|
||||
|
||||
- **URL**: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
- **Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Discord Webhook Push
|
||||
|
||||
- **URL**: `https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
- **Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook Data Format
|
||||
|
||||
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}`
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
|
||||
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies | `true` |
|
||||
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
|
||||
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance, will be 0 if not set | `1` |
|
||||
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
| `ADMIN_CONTACT` | Text | Admin contact information, can be any string, hidden if not configured | `xxx@gmail.com` |
|
||||
| `DISABLE_SHOW_GITHUB` | Text/JSON | Whether to show GitHub link | `true` |
|
||||
| `STATUS_URL` | Text | Status monitoring page URL, shows Status menu button when configured | `https://status.example.com` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
|
||||
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | Text/JSON | Enable global Turnstile CAPTCHA for all login forms (admin login, user login, address password login), requires Turnstile keys above | `true` |
|
||||
|
||||
## Telegram Bot Related Variables
|
||||
|
||||
@@ -111,6 +112,7 @@
|
||||
| `TG_MAX_ADDRESS` | Number | Maximum number of mailboxes that can be bound to telegram bot | `5` |
|
||||
| `TG_BOT_INFO` | Text | Optional, telegram BOT_INFO, predefined BOT_INFO can reduce webhook latency | `{}` |
|
||||
| `TG_ALLOW_USER_LANG` | Text/JSON | Allow users to switch language via `/lang` command, default `false` | `true` |
|
||||
| `ENABLE_TG_PUSH_ATTACHMENT` | Boolean | Enable sending email attachments via Telegram push, default `false`, 50MB per file limit | `true` |
|
||||
|
||||
> [!NOTE]
|
||||
> Telegram functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout
|
||||
|
||||
@@ -17,6 +17,8 @@ res = requests.get(
|
||||
)
|
||||
```
|
||||
|
||||
**注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。
|
||||
|
||||
## admin 邮件 API
|
||||
|
||||
支持 `address` 过滤
|
||||
@@ -43,6 +45,8 @@ response = requests.get(url, headers=headers, params=querystring)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
**注意**:`/admin/mails` 与 `/api/mails` 一致,返回的是邮件数据库中的 raw MIME 内容;如需正文/主题等可读字段,请在客户端自行解析 `raw`。
|
||||
|
||||
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
|
||||
|
||||
## admin 删除邮件 API
|
||||
@@ -151,4 +155,6 @@ response = requests.get(url, headers=headers, params=querystring)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
**注意**:`/user_api/mails` 同样返回原始 RFC822 内容;请在客户端解析后提取 `subject`、`text`、`html`。
|
||||
|
||||
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
|
||||
|
||||
@@ -55,6 +55,42 @@ Telegram Bot 支持中英文切换,用户可以通过 `/lang` 命令设置语
|
||||
|
||||
语言偏好会保存到 KV 中,每个用户可以独立设置。
|
||||
|
||||
## 每用户邮件推送
|
||||
|
||||
Telegram Bot 支持 **每用户独立推送**,用户绑定地址后,该地址收到的邮件会自动推送给对应用户。
|
||||
|
||||
### 用户操作流程
|
||||
|
||||
1. 在 Telegram 中找到你部署的 Bot
|
||||
2. 使用 `/new [name@domain]` 创建新邮箱地址,或使用 `/bind <credential>` 绑定已有地址
|
||||
3. 绑定后,该地址收到邮件时会 **自动推送通知给你**
|
||||
4. 使用 `/address` 查看已绑定的地址列表
|
||||
5. 使用 `/unbind <address>` 解绑地址
|
||||
|
||||
> [!TIP]
|
||||
> 每个用户最多可绑定 `TG_MAX_ADDRESS`(默认 5)个地址
|
||||
|
||||
### 全局推送
|
||||
|
||||
管理员可以在后台 `设置` -> `Telegram` 页面开启 **全局邮件推送**,将所有邮件推送给指定的 Telegram 用户 ID 列表。
|
||||
|
||||
- `enableGlobalMailPush`: 是否开启全局推送
|
||||
- `globalMailPushList`: 接收全局推送的 Telegram 用户 ID 列表
|
||||
|
||||
> [!NOTE]
|
||||
> 全局推送和每用户推送可以同时生效。如果某地址已绑定用户,同时该用户也在全局推送列表中,则会收到两条通知。
|
||||
|
||||
### 附件推送
|
||||
|
||||
> [!NOTE]
|
||||
> 此功能从 v1.5.0 版本开始支持
|
||||
|
||||
配置 `ENABLE_TG_PUSH_ATTACHMENT = true` 后,邮件附件会随推送一起发送到 Telegram。
|
||||
|
||||
- 单个附件大小限制 50MB(Telegram Bot API 限制),超过的附件会被跳过
|
||||
- 多附件通过 `sendMediaGroup` 批量发送,每批最多 6 个
|
||||
- 第一个附件会附带邮件发件人和主题信息作为 caption
|
||||
|
||||
## Mini App
|
||||
|
||||
可以通过命令行部署,或者 UI 界面部署
|
||||
|
||||
@@ -26,6 +26,77 @@
|
||||
|
||||

|
||||
|
||||
## Webhook 模板示例
|
||||
|
||||
### Telegram Bot 推送
|
||||
|
||||
通过 Webhook 直接调用 Telegram Bot API 推送邮件通知,适合不想部署完整 Telegram Bot 集成或需要自定义推送格式的场景。
|
||||
|
||||
- **URL**: `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
- **Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_id": "YOUR_CHAT_ID",
|
||||
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 获取 `chat_id`:向 Bot 发送一条消息,然后访问 `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates` 查看返回结果中的 `chat.id` 字段
|
||||
|
||||
### 企业微信机器人推送
|
||||
|
||||
- **URL**: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
- **Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Discord Webhook 推送
|
||||
|
||||
- **URL**: `https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN`
|
||||
- **Method**: `POST`
|
||||
- **Headers**:
|
||||
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
|
||||
- **Body**:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
```
|
||||
|
||||
## webhook 数据格式
|
||||
|
||||
要获取 url 需要配置 worker 的 `FRONTEND_URL` 为你的前端地址,或者你可以通过 `id` 自己拼接 url = `${FRONTEND_URL}?mail_id=${id}`
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
|
||||
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` |
|
||||
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/`) | `true` |
|
||||
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
|
||||
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
|
||||
|
||||
@@ -101,8 +101,9 @@
|
||||
| `ADMIN_CONTACT` | 文本 | admin 联系方式,可配置任意字符串, 不配置则不显示 | `xxx@gmail.com` |
|
||||
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否显示 GitHub 链接 | `true` |
|
||||
| `STATUS_URL` | 文本 | 状态监控页面 URL,配置后显示 Status 菜单按钮 | `https://status.example.com` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
|
||||
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
|
||||
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
|
||||
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | 文本/JSON | 启用全局登录表单的 Turnstile 人机验证(管理员登录、用户登录、邮箱密码登录),需同时配置上述 Turnstile 密钥 | `true` |
|
||||
|
||||
## Telegram Bot 相关变量
|
||||
|
||||
@@ -111,6 +112,7 @@
|
||||
| `TG_MAX_ADDRESS` | 数字 | telegram bot 最多绑定邮箱数量 | `5` |
|
||||
| `TG_BOT_INFO` | 文本 | 可不配置,telegram BOT_INFO,预定义的 BOT_INFO 可以降低 webhook 的延迟 | `{}` |
|
||||
| `TG_ALLOW_USER_LANG`| 文本/JSON | 是否允许用户通过 `/lang` 命令切换语言,默认 `false` | `true`|
|
||||
| `ENABLE_TG_PUSH_ATTACHMENT`| 布尔值 | 是否启用 Telegram 推送邮件附件,默认 `false`,单文件限制 50MB | `true`|
|
||||
|
||||
> [!NOTE]
|
||||
> Telegram 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/node": "^25.4.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"wrangler": "^4.70.0"
|
||||
"wrangler": "^4.72.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
|
||||
486
vitepress-docs/pnpm-lock.yaml
generated
486
vitepress-docs/pnpm-lock.yaml
generated
@@ -13,19 +13,19 @@ importers:
|
||||
version: 3.10.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.3.3
|
||||
version: 25.3.3
|
||||
specifier: ^25.4.0
|
||||
version: 25.4.0
|
||||
vitepress:
|
||||
specifier: ^1.6.4
|
||||
version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@25.3.3)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5)
|
||||
version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@25.4.0)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5)
|
||||
wrangler:
|
||||
specifier: ^4.70.0
|
||||
version: 4.70.0
|
||||
specifier: ^4.72.0
|
||||
version: 4.72.0
|
||||
|
||||
packages:
|
||||
|
||||
'@algolia/abtesting@1.15.1':
|
||||
resolution: {integrity: sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==}
|
||||
'@algolia/abtesting@1.15.2':
|
||||
resolution: {integrity: sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/autocomplete-core@1.17.7':
|
||||
@@ -48,56 +48,56 @@ packages:
|
||||
'@algolia/client-search': '>= 4.9.1 < 6'
|
||||
algoliasearch: '>= 4.9.1 < 6'
|
||||
|
||||
'@algolia/client-abtesting@5.49.1':
|
||||
resolution: {integrity: sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==}
|
||||
'@algolia/client-abtesting@5.49.2':
|
||||
resolution: {integrity: sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/client-analytics@5.49.1':
|
||||
resolution: {integrity: sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==}
|
||||
'@algolia/client-analytics@5.49.2':
|
||||
resolution: {integrity: sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/client-common@5.49.1':
|
||||
resolution: {integrity: sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==}
|
||||
'@algolia/client-common@5.49.2':
|
||||
resolution: {integrity: sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/client-insights@5.49.1':
|
||||
resolution: {integrity: sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==}
|
||||
'@algolia/client-insights@5.49.2':
|
||||
resolution: {integrity: sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/client-personalization@5.49.1':
|
||||
resolution: {integrity: sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==}
|
||||
'@algolia/client-personalization@5.49.2':
|
||||
resolution: {integrity: sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/client-query-suggestions@5.49.1':
|
||||
resolution: {integrity: sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==}
|
||||
'@algolia/client-query-suggestions@5.49.2':
|
||||
resolution: {integrity: sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/client-search@5.49.1':
|
||||
resolution: {integrity: sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==}
|
||||
'@algolia/client-search@5.49.2':
|
||||
resolution: {integrity: sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/ingestion@1.49.1':
|
||||
resolution: {integrity: sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==}
|
||||
'@algolia/ingestion@1.49.2':
|
||||
resolution: {integrity: sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/monitoring@1.49.1':
|
||||
resolution: {integrity: sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==}
|
||||
'@algolia/monitoring@1.49.2':
|
||||
resolution: {integrity: sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/recommend@5.49.1':
|
||||
resolution: {integrity: sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==}
|
||||
'@algolia/recommend@5.49.2':
|
||||
resolution: {integrity: sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/requester-browser-xhr@5.49.1':
|
||||
resolution: {integrity: sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==}
|
||||
'@algolia/requester-browser-xhr@5.49.2':
|
||||
resolution: {integrity: sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/requester-fetch@5.49.1':
|
||||
resolution: {integrity: sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==}
|
||||
'@algolia/requester-fetch@5.49.2':
|
||||
resolution: {integrity: sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@algolia/requester-node-http@5.49.1':
|
||||
resolution: {integrity: sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==}
|
||||
'@algolia/requester-node-http@5.49.2':
|
||||
resolution: {integrity: sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@babel/helper-string-parser@7.27.1':
|
||||
@@ -121,41 +121,41 @@ packages:
|
||||
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@cloudflare/unenv-preset@2.14.0':
|
||||
resolution: {integrity: sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==}
|
||||
'@cloudflare/unenv-preset@2.15.0':
|
||||
resolution: {integrity: sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==}
|
||||
peerDependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: ^1.20260218.0
|
||||
workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0
|
||||
peerDependenciesMeta:
|
||||
workerd:
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260301.1':
|
||||
resolution: {integrity: sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ==}
|
||||
'@cloudflare/workerd-darwin-64@1.20260310.1':
|
||||
resolution: {integrity: sha512-hF2VpoWaMb1fiGCQJqCY6M8I+2QQqjkyY4LiDYdTL5D/w6C1l5v1zhc0/jrjdD1DXfpJtpcSMSmEPjHse4p9Ig==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260301.1':
|
||||
resolution: {integrity: sha512-PPIetY3e67YBr9O4UhILK8nbm5TqUDl14qx4rwFNrRSBOvlzuczzbd4BqgpAtbGVFxKp1PWpjAnBvGU/OI/tLQ==}
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
|
||||
resolution: {integrity: sha512-h/Vl3XrYYPI6yFDE27XO1QPq/1G1lKIM8tzZGIWYpntK3IN5XtH3Ee/sLaegpJ49aIJoqhF2mVAZ6Yw+Vk2gJw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260301.1':
|
||||
resolution: {integrity: sha512-Gu5vaVTZuYl3cHa+u5CDzSVDBvSkfNyuAHi6Mdfut7TTUdcb3V5CIcR/mXRSyMXzEy9YxEWIfdKMxOMBjupvYQ==}
|
||||
'@cloudflare/workerd-linux-64@1.20260310.1':
|
||||
resolution: {integrity: sha512-XzQ0GZ8G5P4d74bQYOIP2Su4CLdNPpYidrInaSOuSxMw+HamsHaFrjVsrV2mPy/yk2hi6SY2yMbgKFK9YjA7vw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260301.1':
|
||||
resolution: {integrity: sha512-igL1pkyCXW6GiGpjdOAvqMi87UW0LMc/+yIQe/CSzuZJm5GzXoAMrwVTkCFnikk6JVGELrM5x0tGYlxa0sk5Iw==}
|
||||
'@cloudflare/workerd-linux-arm64@1.20260310.1':
|
||||
resolution: {integrity: sha512-sxv4CxnN4ZR0uQGTFVGa0V4KTqwdej/czpIc5tYS86G8FQQoGIBiAIs2VvU7b8EROPcandxYHDBPTb+D9HIMPw==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260301.1':
|
||||
resolution: {integrity: sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A==}
|
||||
'@cloudflare/workerd-windows-64@1.20260310.1':
|
||||
resolution: {integrity: sha512-+1ZTViWKJypLfgH/luAHCqkent0DEBjAjvO40iAhOMHRLYP/SPphLvr4Jpi6lb+sIocS8Q1QZL4uM5Etg1Wskg==}
|
||||
engines: {node: '>=16'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -484,8 +484,8 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@iconify-json/simple-icons@1.2.72':
|
||||
resolution: {integrity: sha512-wkcixntHvaCoqPqerGrNFcHQ3Yx1ux4ZkhscCDK0DEHpP62XCH+cxq1HTsRjbUiQl/M9K8bj03HF6Wgn5iE2rQ==}
|
||||
'@iconify-json/simple-icons@1.2.73':
|
||||
resolution: {integrity: sha512-nQZTwul4c2zBqH/aLP4zMOiElj93T6HawbrP+sFQKpxmBdS5x1duCK3cAnkj6dntHz84EYkzaQRM83V2pj4qxA==}
|
||||
|
||||
'@iconify/types@2.0.0':
|
||||
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
|
||||
@@ -820,8 +820,8 @@ packages:
|
||||
'@types/mdurl@2.0.0':
|
||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||
|
||||
'@types/node@25.3.3':
|
||||
resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==}
|
||||
'@types/node@25.4.0':
|
||||
resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
@@ -839,17 +839,17 @@ packages:
|
||||
vite: ^5.0.0 || ^6.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vue/compiler-core@3.5.29':
|
||||
resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==}
|
||||
'@vue/compiler-core@3.5.30':
|
||||
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
|
||||
|
||||
'@vue/compiler-dom@3.5.29':
|
||||
resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==}
|
||||
'@vue/compiler-dom@3.5.30':
|
||||
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.29':
|
||||
resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==}
|
||||
'@vue/compiler-sfc@3.5.30':
|
||||
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.29':
|
||||
resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==}
|
||||
'@vue/compiler-ssr@3.5.30':
|
||||
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
|
||||
|
||||
'@vue/devtools-api@7.7.9':
|
||||
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
|
||||
@@ -860,22 +860,22 @@ packages:
|
||||
'@vue/devtools-shared@7.7.9':
|
||||
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
|
||||
|
||||
'@vue/reactivity@3.5.29':
|
||||
resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==}
|
||||
'@vue/reactivity@3.5.30':
|
||||
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
|
||||
|
||||
'@vue/runtime-core@3.5.29':
|
||||
resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==}
|
||||
'@vue/runtime-core@3.5.30':
|
||||
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
|
||||
|
||||
'@vue/runtime-dom@3.5.29':
|
||||
resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==}
|
||||
'@vue/runtime-dom@3.5.30':
|
||||
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
|
||||
|
||||
'@vue/server-renderer@3.5.29':
|
||||
resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==}
|
||||
'@vue/server-renderer@3.5.30':
|
||||
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
|
||||
peerDependencies:
|
||||
vue: 3.5.29
|
||||
vue: 3.5.30
|
||||
|
||||
'@vue/shared@3.5.29':
|
||||
resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
|
||||
'@vue/shared@3.5.30':
|
||||
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
|
||||
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
@@ -927,8 +927,8 @@ packages:
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
algoliasearch@5.49.1:
|
||||
resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==}
|
||||
algoliasearch@5.49.2:
|
||||
resolution: {integrity: sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
birpc@2.9.0:
|
||||
@@ -1064,8 +1064,8 @@ packages:
|
||||
micromark-util-types@2.0.2:
|
||||
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
|
||||
|
||||
miniflare@4.20260301.1:
|
||||
resolution: {integrity: sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog==}
|
||||
miniflare@4.20260310.0:
|
||||
resolution: {integrity: sha512-uC5vNPenFpDSj5aUU3wGSABG6UUqMr+Xs1m4AkCrTHo37F4Z6xcQw5BXqViTfPDVT/zcYH1UgTVoXhr1l6ZMXw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1102,8 +1102,8 @@ packages:
|
||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
preact@10.28.4:
|
||||
resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==}
|
||||
preact@10.29.0:
|
||||
resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
@@ -1268,25 +1268,25 @@ packages:
|
||||
postcss:
|
||||
optional: true
|
||||
|
||||
vue@3.5.29:
|
||||
resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==}
|
||||
vue@3.5.30:
|
||||
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
workerd@1.20260301.1:
|
||||
resolution: {integrity: sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw==}
|
||||
workerd@1.20260310.1:
|
||||
resolution: {integrity: sha512-yawXhypXXHtArikJj15HOMknNGikpBbSg2ZDe6lddUbqZnJXuCVSkgc/0ArUeVMG1jbbGvpst+REFtKwILvRTQ==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
wrangler@4.70.0:
|
||||
resolution: {integrity: sha512-PNDZ9o4e+B5x+1bUbz62Hmwz6G9lw+I9pnYe/AguLddJFjfIyt2cmFOUOb3eOZSoXsrhcEPUg2YidYIbVwUkfw==}
|
||||
wrangler@4.72.0:
|
||||
resolution: {integrity: sha512-bKkb8150JGzJZJWiNB2nu/33smVfawmfYiecA6rW4XH7xS23/jqMbgpdelM34W/7a1IhR66qeQGVqTRXROtAZg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@cloudflare/workers-types': ^4.20260226.1
|
||||
'@cloudflare/workers-types': ^4.20260310.1
|
||||
peerDependenciesMeta:
|
||||
'@cloudflare/workers-types':
|
||||
optional: true
|
||||
@@ -1314,117 +1314,117 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@algolia/abtesting@1.15.1':
|
||||
'@algolia/abtesting@1.15.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)':
|
||||
'@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)':
|
||||
dependencies:
|
||||
'@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)
|
||||
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
||||
'@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)
|
||||
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
|
||||
transitivePeerDependencies:
|
||||
- '@algolia/client-search'
|
||||
- algoliasearch
|
||||
- search-insights
|
||||
|
||||
'@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)':
|
||||
'@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)':
|
||||
dependencies:
|
||||
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
||||
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
|
||||
search-insights: 2.13.0
|
||||
transitivePeerDependencies:
|
||||
- '@algolia/client-search'
|
||||
- algoliasearch
|
||||
|
||||
'@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)':
|
||||
'@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)':
|
||||
dependencies:
|
||||
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
||||
'@algolia/client-search': 5.49.1
|
||||
algoliasearch: 5.49.1
|
||||
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
|
||||
'@algolia/client-search': 5.49.2
|
||||
algoliasearch: 5.49.2
|
||||
|
||||
'@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)':
|
||||
'@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)':
|
||||
dependencies:
|
||||
'@algolia/client-search': 5.49.1
|
||||
algoliasearch: 5.49.1
|
||||
'@algolia/client-search': 5.49.2
|
||||
algoliasearch: 5.49.2
|
||||
|
||||
'@algolia/client-abtesting@5.49.1':
|
||||
'@algolia/client-abtesting@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/client-analytics@5.49.1':
|
||||
'@algolia/client-analytics@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/client-common@5.49.1': {}
|
||||
'@algolia/client-common@5.49.2': {}
|
||||
|
||||
'@algolia/client-insights@5.49.1':
|
||||
'@algolia/client-insights@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/client-personalization@5.49.1':
|
||||
'@algolia/client-personalization@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/client-query-suggestions@5.49.1':
|
||||
'@algolia/client-query-suggestions@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/client-search@5.49.1':
|
||||
'@algolia/client-search@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/ingestion@1.49.1':
|
||||
'@algolia/ingestion@1.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/monitoring@1.49.1':
|
||||
'@algolia/monitoring@1.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/recommend@5.49.1':
|
||||
'@algolia/recommend@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
'@algolia/requester-browser-xhr@5.49.1':
|
||||
'@algolia/requester-browser-xhr@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
|
||||
'@algolia/requester-fetch@5.49.1':
|
||||
'@algolia/requester-fetch@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
|
||||
'@algolia/requester-node-http@5.49.1':
|
||||
'@algolia/requester-node-http@5.49.2':
|
||||
dependencies:
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/client-common': 5.49.2
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
|
||||
@@ -1441,25 +1441,25 @@ snapshots:
|
||||
|
||||
'@cloudflare/kv-asset-handler@0.4.2': {}
|
||||
|
||||
'@cloudflare/unenv-preset@2.14.0(unenv@2.0.0-rc.24)(workerd@1.20260301.1)':
|
||||
'@cloudflare/unenv-preset@2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)':
|
||||
dependencies:
|
||||
unenv: 2.0.0-rc.24
|
||||
optionalDependencies:
|
||||
workerd: 1.20260301.1
|
||||
workerd: 1.20260310.1
|
||||
|
||||
'@cloudflare/workerd-darwin-64@1.20260301.1':
|
||||
'@cloudflare/workerd-darwin-64@1.20260310.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260301.1':
|
||||
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-64@1.20260301.1':
|
||||
'@cloudflare/workerd-linux-64@1.20260310.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-linux-arm64@1.20260301.1':
|
||||
'@cloudflare/workerd-linux-arm64@1.20260310.1':
|
||||
optional: true
|
||||
|
||||
'@cloudflare/workerd-windows-64@1.20260301.1':
|
||||
'@cloudflare/workerd-windows-64@1.20260310.1':
|
||||
optional: true
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
@@ -1468,10 +1468,10 @@ snapshots:
|
||||
|
||||
'@docsearch/css@3.8.2': {}
|
||||
|
||||
'@docsearch/js@3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)':
|
||||
'@docsearch/js@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)':
|
||||
dependencies:
|
||||
'@docsearch/react': 3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)
|
||||
preact: 10.28.4
|
||||
'@docsearch/react': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)
|
||||
preact: 10.29.0
|
||||
transitivePeerDependencies:
|
||||
- '@algolia/client-search'
|
||||
- '@types/react'
|
||||
@@ -1479,12 +1479,12 @@ snapshots:
|
||||
- react-dom
|
||||
- search-insights
|
||||
|
||||
'@docsearch/react@3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)':
|
||||
'@docsearch/react@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)':
|
||||
dependencies:
|
||||
'@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)
|
||||
'@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
||||
'@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)
|
||||
'@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
|
||||
'@docsearch/css': 3.8.2
|
||||
algoliasearch: 5.49.1
|
||||
algoliasearch: 5.49.2
|
||||
optionalDependencies:
|
||||
search-insights: 2.13.0
|
||||
transitivePeerDependencies:
|
||||
@@ -1642,7 +1642,7 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.27.3':
|
||||
optional: true
|
||||
|
||||
'@iconify-json/simple-icons@1.2.72':
|
||||
'@iconify-json/simple-icons@1.2.73':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
@@ -1903,7 +1903,7 @@ snapshots:
|
||||
|
||||
'@types/mdurl@2.0.0': {}
|
||||
|
||||
'@types/node@25.3.3':
|
||||
'@types/node@25.4.0':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
@@ -1913,40 +1913,40 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.3.3))(vue@3.5.29(typescript@5.4.5))':
|
||||
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.4.0))(vue@3.5.30(typescript@5.4.5))':
|
||||
dependencies:
|
||||
vite: 5.4.21(@types/node@25.3.3)
|
||||
vue: 3.5.29(typescript@5.4.5)
|
||||
vite: 5.4.21(@types/node@25.4.0)
|
||||
vue: 3.5.30(typescript@5.4.5)
|
||||
|
||||
'@vue/compiler-core@3.5.29':
|
||||
'@vue/compiler-core@3.5.30':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/shared': 3.5.30
|
||||
entities: 7.0.1
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-dom@3.5.29':
|
||||
'@vue/compiler-dom@3.5.30':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.29
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/compiler-core': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
|
||||
'@vue/compiler-sfc@3.5.29':
|
||||
'@vue/compiler-sfc@3.5.30':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.0
|
||||
'@vue/compiler-core': 3.5.29
|
||||
'@vue/compiler-dom': 3.5.29
|
||||
'@vue/compiler-ssr': 3.5.29
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/compiler-core': 3.5.30
|
||||
'@vue/compiler-dom': 3.5.30
|
||||
'@vue/compiler-ssr': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.21
|
||||
postcss: 8.5.8
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-ssr@3.5.29':
|
||||
'@vue/compiler-ssr@3.5.30':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.29
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/compiler-dom': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
|
||||
'@vue/devtools-api@7.7.9':
|
||||
dependencies:
|
||||
@@ -1966,36 +1966,36 @@ snapshots:
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/reactivity@3.5.29':
|
||||
'@vue/reactivity@3.5.30':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/shared': 3.5.30
|
||||
|
||||
'@vue/runtime-core@3.5.29':
|
||||
'@vue/runtime-core@3.5.30':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.29
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/reactivity': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
|
||||
'@vue/runtime-dom@3.5.29':
|
||||
'@vue/runtime-dom@3.5.30':
|
||||
dependencies:
|
||||
'@vue/reactivity': 3.5.29
|
||||
'@vue/runtime-core': 3.5.29
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/reactivity': 3.5.30
|
||||
'@vue/runtime-core': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
csstype: 3.2.3
|
||||
|
||||
'@vue/server-renderer@3.5.29(vue@3.5.29(typescript@5.4.5))':
|
||||
'@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.4.5))':
|
||||
dependencies:
|
||||
'@vue/compiler-ssr': 3.5.29
|
||||
'@vue/shared': 3.5.29
|
||||
vue: 3.5.29(typescript@5.4.5)
|
||||
'@vue/compiler-ssr': 3.5.30
|
||||
'@vue/shared': 3.5.30
|
||||
vue: 3.5.30(typescript@5.4.5)
|
||||
|
||||
'@vue/shared@3.5.29': {}
|
||||
'@vue/shared@3.5.30': {}
|
||||
|
||||
'@vueuse/core@12.8.2(typescript@5.4.5)':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 12.8.2
|
||||
'@vueuse/shared': 12.8.2(typescript@5.4.5)
|
||||
vue: 3.5.29(typescript@5.4.5)
|
||||
vue: 3.5.30(typescript@5.4.5)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -2003,7 +2003,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@vueuse/core': 12.8.2(typescript@5.4.5)
|
||||
'@vueuse/shared': 12.8.2(typescript@5.4.5)
|
||||
vue: 3.5.29(typescript@5.4.5)
|
||||
vue: 3.5.30(typescript@5.4.5)
|
||||
optionalDependencies:
|
||||
focus-trap: 7.8.0
|
||||
transitivePeerDependencies:
|
||||
@@ -2013,26 +2013,26 @@ snapshots:
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.4.5)':
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.4.5)
|
||||
vue: 3.5.30(typescript@5.4.5)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
algoliasearch@5.49.1:
|
||||
algoliasearch@5.49.2:
|
||||
dependencies:
|
||||
'@algolia/abtesting': 1.15.1
|
||||
'@algolia/client-abtesting': 5.49.1
|
||||
'@algolia/client-analytics': 5.49.1
|
||||
'@algolia/client-common': 5.49.1
|
||||
'@algolia/client-insights': 5.49.1
|
||||
'@algolia/client-personalization': 5.49.1
|
||||
'@algolia/client-query-suggestions': 5.49.1
|
||||
'@algolia/client-search': 5.49.1
|
||||
'@algolia/ingestion': 1.49.1
|
||||
'@algolia/monitoring': 1.49.1
|
||||
'@algolia/recommend': 5.49.1
|
||||
'@algolia/requester-browser-xhr': 5.49.1
|
||||
'@algolia/requester-fetch': 5.49.1
|
||||
'@algolia/requester-node-http': 5.49.1
|
||||
'@algolia/abtesting': 1.15.2
|
||||
'@algolia/client-abtesting': 5.49.2
|
||||
'@algolia/client-analytics': 5.49.2
|
||||
'@algolia/client-common': 5.49.2
|
||||
'@algolia/client-insights': 5.49.2
|
||||
'@algolia/client-personalization': 5.49.2
|
||||
'@algolia/client-query-suggestions': 5.49.2
|
||||
'@algolia/client-search': 5.49.2
|
||||
'@algolia/ingestion': 1.49.2
|
||||
'@algolia/monitoring': 1.49.2
|
||||
'@algolia/recommend': 5.49.2
|
||||
'@algolia/requester-browser-xhr': 5.49.2
|
||||
'@algolia/requester-fetch': 5.49.2
|
||||
'@algolia/requester-node-http': 5.49.2
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
@@ -2212,12 +2212,12 @@ snapshots:
|
||||
|
||||
micromark-util-types@2.0.2: {}
|
||||
|
||||
miniflare@4.20260301.1:
|
||||
miniflare@4.20260310.0:
|
||||
dependencies:
|
||||
'@cspotcode/source-map-support': 0.8.1
|
||||
sharp: 0.34.5
|
||||
undici: 7.18.2
|
||||
workerd: 1.20260301.1
|
||||
workerd: 1.20260310.1
|
||||
ws: 8.18.0
|
||||
youch: 4.1.0-beta.10
|
||||
transitivePeerDependencies:
|
||||
@@ -2252,7 +2252,7 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
preact@10.28.4: {}
|
||||
preact@10.29.0: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
@@ -2435,35 +2435,35 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@5.4.21(@types/node@25.3.3):
|
||||
vite@5.4.21(@types/node@25.4.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.8
|
||||
rollup: 4.59.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.3
|
||||
'@types/node': 25.4.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@25.3.3)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5):
|
||||
vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@25.4.0)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5):
|
||||
dependencies:
|
||||
'@docsearch/css': 3.8.2
|
||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)
|
||||
'@iconify-json/simple-icons': 1.2.72
|
||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)
|
||||
'@iconify-json/simple-icons': 1.2.73
|
||||
'@shikijs/core': 2.5.0
|
||||
'@shikijs/transformers': 2.5.0
|
||||
'@shikijs/types': 2.5.0
|
||||
'@types/markdown-it': 14.1.2
|
||||
'@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.3.3))(vue@3.5.29(typescript@5.4.5))
|
||||
'@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.4.0))(vue@3.5.30(typescript@5.4.5))
|
||||
'@vue/devtools-api': 7.7.9
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/shared': 3.5.30
|
||||
'@vueuse/core': 12.8.2(typescript@5.4.5)
|
||||
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@5.4.5)
|
||||
focus-trap: 7.8.0
|
||||
mark.js: 8.11.1
|
||||
minisearch: 7.2.0
|
||||
shiki: 2.5.0
|
||||
vite: 5.4.21(@types/node@25.3.3)
|
||||
vue: 3.5.29(typescript@5.4.5)
|
||||
vite: 5.4.21(@types/node@25.4.0)
|
||||
vue: 3.5.30(typescript@5.4.5)
|
||||
optionalDependencies:
|
||||
postcss: 8.5.8
|
||||
transitivePeerDependencies:
|
||||
@@ -2493,34 +2493,34 @@ snapshots:
|
||||
- typescript
|
||||
- universal-cookie
|
||||
|
||||
vue@3.5.29(typescript@5.4.5):
|
||||
vue@3.5.30(typescript@5.4.5):
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.29
|
||||
'@vue/compiler-sfc': 3.5.29
|
||||
'@vue/runtime-dom': 3.5.29
|
||||
'@vue/server-renderer': 3.5.29(vue@3.5.29(typescript@5.4.5))
|
||||
'@vue/shared': 3.5.29
|
||||
'@vue/compiler-dom': 3.5.30
|
||||
'@vue/compiler-sfc': 3.5.30
|
||||
'@vue/runtime-dom': 3.5.30
|
||||
'@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.4.5))
|
||||
'@vue/shared': 3.5.30
|
||||
optionalDependencies:
|
||||
typescript: 5.4.5
|
||||
|
||||
workerd@1.20260301.1:
|
||||
workerd@1.20260310.1:
|
||||
optionalDependencies:
|
||||
'@cloudflare/workerd-darwin-64': 1.20260301.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260301.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260301.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260301.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260301.1
|
||||
'@cloudflare/workerd-darwin-64': 1.20260310.1
|
||||
'@cloudflare/workerd-darwin-arm64': 1.20260310.1
|
||||
'@cloudflare/workerd-linux-64': 1.20260310.1
|
||||
'@cloudflare/workerd-linux-arm64': 1.20260310.1
|
||||
'@cloudflare/workerd-windows-64': 1.20260310.1
|
||||
|
||||
wrangler@4.70.0:
|
||||
wrangler@4.72.0:
|
||||
dependencies:
|
||||
'@cloudflare/kv-asset-handler': 0.4.2
|
||||
'@cloudflare/unenv-preset': 2.14.0(unenv@2.0.0-rc.24)(workerd@1.20260301.1)
|
||||
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)
|
||||
blake3-wasm: 2.1.5
|
||||
esbuild: 0.27.3
|
||||
miniflare: 4.20260301.1
|
||||
miniflare: 4.20260310.0
|
||||
path-to-regexp: 6.3.0
|
||||
unenv: 2.0.0-rc.24
|
||||
workerd: 1.20260301.1
|
||||
workerd: 1.20260310.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,20 +11,19 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260305.1",
|
||||
"@cloudflare/workers-types": "^4.20260310.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@simplewebauthn/types": "10.0.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/node": "^25.4.0",
|
||||
"eslint": "9.39.1",
|
||||
"globals": "^16.5.0",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"wrangler": "^4.70.0"
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"wrangler": "^4.72.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.888.0",
|
||||
"@simplewebauthn/server": "10.0.1",
|
||||
"hono": "^4.12.5",
|
||||
"@simplewebauthn/server": "13.2.3",
|
||||
"hono": "^4.12.7",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"mimetext": "^3.0.28",
|
||||
"postal-mime": "^2.7.3",
|
||||
|
||||
520
worker/pnpm-lock.yaml
generated
520
worker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -44,20 +44,19 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
|
||||
if (!headers.has('Message-ID')) headers.set('Message-ID', `<e2e-${Date.now()}@test>`);
|
||||
|
||||
const rawBytes = new TextEncoder().encode(raw);
|
||||
let rejected: string | undefined;
|
||||
const state = { rejected: undefined as string | undefined, replyCalled: false };
|
||||
const mockMessage: ForwardableEmailMessage = {
|
||||
from, to, headers,
|
||||
rawSize: rawBytes.byteLength,
|
||||
raw: new ReadableStream({ start(ctrl) { ctrl.enqueue(rawBytes); ctrl.close(); } }),
|
||||
setReject(reason: string) { rejected = reason; },
|
||||
setReject(reason: string) { state.rejected = reason; },
|
||||
forward: async () => ({ messageId: '' }),
|
||||
reply: async () => ({ messageId: '' }),
|
||||
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
|
||||
};
|
||||
|
||||
const { email: emailHandler } = await import('../email');
|
||||
await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} });
|
||||
|
||||
return c.json({ success: !rejected, ...(rejected ? { rejected } : {}) });
|
||||
return c.json({ success: !state.rejected, replyCalled: state.replyCalled, ...(state.rejected ? { rejected: state.rejected } : {}) });
|
||||
};
|
||||
|
||||
export default { seedMail, receiveMail };
|
||||
|
||||
@@ -44,7 +44,8 @@ api.get('/open_api/settings', async (c) => {
|
||||
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
|
||||
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
|
||||
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD),
|
||||
"statusUrl": utils.getStringValue(c.env.STATUS_URL)
|
||||
"statusUrl": utils.getStringValue(c.env.STATUS_URL),
|
||||
"enableGlobalTurnstileCheck": utils.isGlobalTurnstileEnabled(c)
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
@@ -456,7 +456,8 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
subject: string,
|
||||
text: string,
|
||||
html: string,
|
||||
headers?: Record<string, string>[]
|
||||
headers?: Record<string, string>[],
|
||||
attachments?: ParsedEmailAttachment[],
|
||||
} | undefined> => {
|
||||
// check parsed email context is valid
|
||||
if (!parsedEmailContext || !parsedEmailContext.rawEmail) {
|
||||
@@ -467,7 +468,7 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
return parsedEmailContext.parsedEmail;
|
||||
}
|
||||
const raw_mail = parsedEmailContext.rawEmail;
|
||||
// TODO: WASM parse email
|
||||
// NOTE: WASM parse email
|
||||
// try {
|
||||
// const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
|
||||
@@ -480,6 +481,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
// (header) => ({ key: header.key, value: header.value })
|
||||
// ) || [],
|
||||
// html: parsedEmail.body_html || "",
|
||||
// attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
// filename: att.filename || "attachment",
|
||||
// mimeType: att.content_type || "application/octet-stream",
|
||||
// content: att.content,
|
||||
// disposition: "attachment",
|
||||
// })),
|
||||
// };
|
||||
// return parsedEmailContext.parsedEmail;
|
||||
// } catch (e) {
|
||||
@@ -494,6 +501,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
text: parsedEmail.text || "",
|
||||
html: parsedEmail.html || "",
|
||||
headers: parsedEmail.headers || [],
|
||||
attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
filename: att.filename || "attachment",
|
||||
mimeType: att.mimeType || "application/octet-stream",
|
||||
content: new Uint8Array(att.content),
|
||||
disposition: att.disposition || "attachment",
|
||||
})),
|
||||
};
|
||||
return parsedEmailContext.parsedEmail;
|
||||
}
|
||||
@@ -605,7 +618,7 @@ export async function triggerWebhook(
|
||||
subject: parsedEmail?.subject || "",
|
||||
raw: parsedEmailContext.rawEmail || "",
|
||||
parsedText: parsedEmail?.text || "",
|
||||
parsedHtml: parsedEmail?.html || ""
|
||||
parsedHtml: parsedEmail?.html || "",
|
||||
}
|
||||
for (const settings of webhookList) {
|
||||
const res = await sendWebhook(settings, webhookMail);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v' + '1.4.0',
|
||||
VERSION: 'v' + '1.5.0',
|
||||
|
||||
// DB Version
|
||||
DB_VERSION_KEY: 'db_version',
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { createMimeMessage } from "mimetext";
|
||||
import { getBooleanValue } from "../utils";
|
||||
|
||||
/**
|
||||
* Check if the sender matches the source_prefix filter.
|
||||
* - empty/undefined: match all senders
|
||||
* - starts and ends with `/`: treat as regex (e.g. `/.*@example\.com$/`)
|
||||
* - otherwise: legacy startsWith match
|
||||
*/
|
||||
function matchSender(from: string, sourcePrefix: string | undefined): boolean {
|
||||
if (!sourcePrefix) return true;
|
||||
if (sourcePrefix.startsWith("/") && sourcePrefix.endsWith("/") && sourcePrefix.length > 2) {
|
||||
try {
|
||||
const regex = new RegExp(sourcePrefix.slice(1, -1));
|
||||
return regex.test(from);
|
||||
} catch (error) {
|
||||
console.error("Invalid regex in source_prefix:", sourcePrefix, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return from.startsWith(sourcePrefix);
|
||||
}
|
||||
|
||||
export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise<void> => {
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// auto reply email
|
||||
@@ -9,7 +29,10 @@ export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings
|
||||
const results = await env.DB.prepare(
|
||||
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
|
||||
).bind(message.to).first<Record<string, string>>();
|
||||
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
|
||||
if (results && matchSender(message.from, results.source_prefix)) {
|
||||
if (!results.subject || !results.message) {
|
||||
console.log("auto-reply using defaults:", !results.subject ? "subject" : "", !results.message ? "message" : "");
|
||||
}
|
||||
const msg = createMimeMessage();
|
||||
msg.setHeader("In-Reply-To", message_id);
|
||||
msg.setSender({
|
||||
@@ -22,14 +45,18 @@ export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings
|
||||
contentType: 'text/plain',
|
||||
data: results.message || "This is an auto-reply message, please reconact later."
|
||||
});
|
||||
const { EmailMessage } = await import('cloudflare:email');
|
||||
const replyMessage = new EmailMessage(
|
||||
message.to,
|
||||
message.from,
|
||||
msg.asRaw()
|
||||
);
|
||||
// @ts-ignore
|
||||
await message.reply(replyMessage);
|
||||
if (getBooleanValue(env.E2E_TEST_MODE)) {
|
||||
await message.reply(msg.asRaw());
|
||||
} else {
|
||||
const { EmailMessage } = await import('cloudflare:email');
|
||||
const replyMessage = new EmailMessage(
|
||||
message.to,
|
||||
message.from,
|
||||
msg.asRaw()
|
||||
);
|
||||
// @ts-ignore
|
||||
await message.reply(replyMessage);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("reply email error", error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context } from 'hono';
|
||||
import i18n from '../i18n';
|
||||
import { getBooleanValue, hashPassword } from '../utils';
|
||||
import utils, { getBooleanValue, hashPassword, checkCfTurnstile } from '../utils';
|
||||
import { Jwt } from 'hono/utils/jwt';
|
||||
|
||||
export default {
|
||||
@@ -49,6 +49,15 @@ export default {
|
||||
return c.text(msgs.EmailPasswordRequiredMsg, 400);
|
||||
}
|
||||
|
||||
// check cf turnstile if global turnstile is enabled
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
|
||||
// 查找地址
|
||||
const address = await c.env.DB.prepare(
|
||||
`SELECT * FROM address WHERE name = ?`
|
||||
|
||||
@@ -130,7 +130,7 @@ api.post('/api/new_address', async (c) => {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 500)
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
// Check if custom email names are disabled from environment variable
|
||||
const disableCustomAddressName = getBooleanValue(c.env.DISABLE_CUSTOM_ADDRESS_NAME);
|
||||
|
||||
@@ -8,7 +8,7 @@ import i18n from '../i18n';
|
||||
import { CONSTANTS } from '../constants'
|
||||
import { getJsonSetting, getDomains, getIntValue, getBooleanValue, getStringValue, getJsonObjectValue, getSplitStringListValue } from '../utils';
|
||||
import { GeoData } from '../models'
|
||||
import { handleListQuery } from '../common'
|
||||
import { handleListQuery, updateAddressUpdatedAt } from '../common'
|
||||
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
@@ -222,6 +222,8 @@ export const sendMail = async (
|
||||
console.warn(`Failed to update balance for ${address}`);
|
||||
}
|
||||
}
|
||||
// update address updated_at
|
||||
updateAddressUpdatedAt(c, address);
|
||||
// save to sendbox
|
||||
try {
|
||||
const reqIp = c.req.raw.headers.get("cf-connecting-ip")
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
AuthenticatorTransportFuture,
|
||||
CredentialDeviceType,
|
||||
Base64URLString,
|
||||
} from '@simplewebauthn/types';
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
export type Passkey = {
|
||||
id: Base64URLString;
|
||||
|
||||
69
worker/src/open_api/auth.ts
Normal file
69
worker/src/open_api/auth.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Hono } from 'hono'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import utils, { checkCfTurnstile, getPasswords, getAdminPasswords, hashPassword } from '../utils';
|
||||
import i18n from '../i18n';
|
||||
|
||||
const api = new Hono<HonoCustomType>()
|
||||
|
||||
api.post('/open_api/site_login', async (c) => {
|
||||
const { password, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
const passwords = getPasswords(c);
|
||||
const hashedPasswords = await Promise.all(passwords.map(p => hashPassword(p)));
|
||||
if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) {
|
||||
return c.text(msgs.CustomAuthPasswordMsg, 401)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
api.post('/open_api/admin_login', async (c) => {
|
||||
const { password, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
const adminPasswords = getAdminPasswords(c);
|
||||
const hashedPasswords = await Promise.all(adminPasswords.map(p => hashPassword(p)));
|
||||
if (!hashedPasswords.length || !password || !hashedPasswords.includes(password)) {
|
||||
return c.text(msgs.NeedAdminPasswordMsg, 401)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
api.post('/open_api/credential_login', async (c) => {
|
||||
const { credential, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
if (!credential) {
|
||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||
}
|
||||
try {
|
||||
const payload = await Jwt.verify(credential, c.env.JWT_SECRET, "HS256");
|
||||
if (!payload.address) {
|
||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||
}
|
||||
} catch (error) {
|
||||
return c.text(msgs.InvalidAddressCredentialMsg, 401)
|
||||
}
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
export { api }
|
||||
@@ -89,7 +89,7 @@ async function newTelegramAddress(c: Context<HonoCustomType>): Promise<Response>
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 500)
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
try {
|
||||
const userId = await checkTelegramAuth(c, initData);
|
||||
|
||||
@@ -6,12 +6,14 @@ import { callbackQuery } from "telegraf/filters";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { getBooleanValue, getDomains, getJsonObjectValue, getStringValue } from '../utils';
|
||||
import { TelegramSettings } from "./settings";
|
||||
import { sendTelegramAttachments } from "./tg_file_upload";
|
||||
import { bindTelegramAddress, deleteTelegramAddress, jwtListToAddressData, tgUserNewAddress, unbindTelegramAddress, unbindTelegramByAddress } from "./common";
|
||||
import { commonParseMail } from "../common";
|
||||
import { UserFromGetMe } from "telegraf/types";
|
||||
import i18n from "../i18n";
|
||||
import { LocaleMessages } from "../i18n/type";
|
||||
|
||||
|
||||
// Helper to get messages by userId
|
||||
const getTgMessages = async (
|
||||
c: Context<HonoCustomType>,
|
||||
@@ -424,6 +426,7 @@ export async function sendMailToTelegram(
|
||||
const buildAndSend = async (targetUserId: string, msgs: LocaleMessages) => {
|
||||
const { mail } = await parseMail(msgs, parsedEmailContext, address, new Date().toUTCString());
|
||||
if (!mail) return;
|
||||
const attachments = parsedEmailContext.parsedEmail?.attachments || [];
|
||||
const buttons = [];
|
||||
if (settings?.miniAppUrl && mailId) {
|
||||
const url = new URL(settings.miniAppUrl);
|
||||
@@ -434,6 +437,11 @@ export async function sendMailToTelegram(
|
||||
await bot.telegram.sendMessage(targetUserId, mail, {
|
||||
...Markup.inlineKeyboard([...buttons])
|
||||
});
|
||||
// send attachments via native fetch (telegraf multipart upload is incompatible with CF Workers)
|
||||
if (getBooleanValue(c.env.ENABLE_TG_PUSH_ATTACHMENT) && attachments.length > 0) {
|
||||
const caption = `From: ${parsedEmailContext.parsedEmail?.sender || ""}\nSubject: ${parsedEmailContext.parsedEmail?.subject || ""}`;
|
||||
await sendTelegramAttachments(c.env.TELEGRAM_BOT_TOKEN, targetUserId, attachments, caption);
|
||||
}
|
||||
};
|
||||
|
||||
if (globalPush) {
|
||||
|
||||
49
worker/src/telegram_api/tg_file_upload.ts
Normal file
49
worker/src/telegram_api/tg_file_upload.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
const TG_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB Telegram Bot API limit
|
||||
|
||||
export async function sendTelegramAttachments(
|
||||
botToken: string,
|
||||
chatId: string,
|
||||
attachments: ParsedEmailAttachment[],
|
||||
caption: string
|
||||
) {
|
||||
try {
|
||||
const validAttachments = attachments.filter(att => {
|
||||
if (att.content.byteLength > TG_MAX_FILE_SIZE) {
|
||||
console.log(`Skipping attachment ${att.filename}: ${(att.content.byteLength / 1024 / 1024).toFixed(1)}MB exceeds 50MB limit`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (validAttachments.length === 0) return;
|
||||
|
||||
const batchSize = 6;
|
||||
for (let i = 0; i < validAttachments.length; i += batchSize) {
|
||||
const batch = validAttachments.slice(i, i + batchSize);
|
||||
const formData = new FormData();
|
||||
const media: { type: string; media: string; caption?: string }[] = [];
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const att = batch[j];
|
||||
const attachKey = `file${j}`;
|
||||
media.push({
|
||||
type: 'document',
|
||||
media: `attach://${attachKey}`,
|
||||
...(i === 0 && j === 0 ? { caption } : {}),
|
||||
});
|
||||
const blob = new Blob([att.content], { type: 'application/octet-stream' });
|
||||
formData.append(attachKey, blob, att.filename || `attachment_${j}`);
|
||||
}
|
||||
formData.append('chat_id', chatId);
|
||||
formData.append('media', JSON.stringify(media));
|
||||
const res = await fetch(
|
||||
`https://api.telegram.org/bot${botToken}/sendMediaGroup`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`Failed to send attachment batch ${i / batchSize + 1}: ${res.status} ${text.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to send telegram attachments:", e);
|
||||
}
|
||||
}
|
||||
11
worker/src/types.d.ts
vendored
11
worker/src/types.d.ts
vendored
@@ -86,6 +86,7 @@ type Bindings = {
|
||||
TG_MAX_ADDRESS: number | undefined
|
||||
TG_BOT_INFO: string | object | undefined
|
||||
TG_ALLOW_USER_LANG: string | boolean | undefined
|
||||
ENABLE_TG_PUSH_ATTACHMENT: string | boolean | undefined
|
||||
|
||||
// webhook config
|
||||
FRONTEND_URL: string | undefined
|
||||
@@ -135,6 +136,13 @@ type RPCEmailMessage = {
|
||||
headers: object | undefined | null,
|
||||
}
|
||||
|
||||
type ParsedEmailAttachment = {
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
content: Uint8Array,
|
||||
disposition: string,
|
||||
}
|
||||
|
||||
type ParsedEmailContext = {
|
||||
rawEmail: string,
|
||||
parsedEmail?: {
|
||||
@@ -142,7 +150,8 @@ type ParsedEmailContext = {
|
||||
subject: string,
|
||||
text: string,
|
||||
html: string,
|
||||
headers?: Record<string, string>[]
|
||||
headers?: Record<string, string>[],
|
||||
attachments?: ParsedEmailAttachment[],
|
||||
} | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import { Passkey } from '../models';
|
||||
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
|
||||
import type { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
import i18n from '../i18n';
|
||||
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
return true;
|
||||
},
|
||||
expectedOrigin: origin,
|
||||
requireUserVerification: true,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
const { verified, registrationInfo } = verification;
|
||||
|
||||
@@ -97,20 +97,21 @@ export default {
|
||||
}
|
||||
|
||||
const {
|
||||
credentialID, credentialPublicKey,
|
||||
counter, credentialDeviceType, credentialBackedUp,
|
||||
} = registrationInfo;
|
||||
id: credentialID, publicKey,
|
||||
counter, deviceType, backedUp,
|
||||
transports,
|
||||
} = registrationInfo.credential;
|
||||
|
||||
// Base64URL encode ArrayBuffers.
|
||||
const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey);
|
||||
const base64PublicKey = isoBase64URL.fromBuffer(publicKey);
|
||||
|
||||
const newPasskey: Passkey = {
|
||||
id: credentialID,
|
||||
publicKey: base64PublicKey,
|
||||
counter,
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
transports: credential?.response?.transports,
|
||||
deviceType,
|
||||
backedUp,
|
||||
transports,
|
||||
};
|
||||
|
||||
// Store the credential ID in the database
|
||||
@@ -161,9 +162,10 @@ export default {
|
||||
},
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: domain,
|
||||
authenticator: {
|
||||
credentialID: passkeyData.id,
|
||||
credentialPublicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
|
||||
requireUserVerification: false,
|
||||
credential: {
|
||||
id: passkeyData.id,
|
||||
publicKey: isoBase64URL.toBuffer(passkeyData.publicKey),
|
||||
counter: counter || passkeyData.counter,
|
||||
transports: passkeyData.transports,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import i18n from '../i18n';
|
||||
import { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
|
||||
import utils, { checkCfTurnstile, getJsonSetting, checkUserPassword, getUserRoles, getStringValue } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { GeoData, UserInfo, UserSettings } from "../models";
|
||||
import { sendMail } from "../mails_api/send_mail_api";
|
||||
@@ -15,7 +15,7 @@ export default {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 500)
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
const value = await getJsonSetting(c, CONSTANTS.USER_SETTINGS_KEY);
|
||||
const settings = new UserSettings(value)
|
||||
@@ -77,11 +77,20 @@ export default {
|
||||
return c.text(msgs.UserRegistrationDisabledMsg, 403);
|
||||
}
|
||||
// check request
|
||||
const { email, password, code } = await c.req.json();
|
||||
const { email, password, code, cf_token } = await c.req.json();
|
||||
if (!email || !password) {
|
||||
return c.text(msgs.InvalidEmailOrPasswordMsg, 400)
|
||||
}
|
||||
checkUserPassword(password);
|
||||
// check cf turnstile only when mail verify is disabled
|
||||
// (when enabled, verify_code endpoint already checks turnstile)
|
||||
if (!settings.enableMailVerify) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
if (settings.enableMailVerify && !code) {
|
||||
return c.text(msgs.InvalidVerifyCodeMsg, 400)
|
||||
}
|
||||
@@ -173,9 +182,17 @@ export default {
|
||||
return c.json({ success: true })
|
||||
},
|
||||
login: async (c: Context<HonoCustomType>) => {
|
||||
const { email, password } = await c.req.json();
|
||||
const { email, password, cf_token } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!email || !password) return c.text(msgs.InvalidEmailOrPasswordMsg, 400);
|
||||
// check cf turnstile if global turnstile is enabled
|
||||
if (utils.isGlobalTurnstileEnabled(c)) {
|
||||
try {
|
||||
await checkCfTurnstile(c, cf_token);
|
||||
} catch (error) {
|
||||
return c.text(msgs.TurnstileCheckFailedMsg, 400)
|
||||
}
|
||||
}
|
||||
const { id: user_id, password: dbPassword } = await c.env.DB.prepare(
|
||||
`SELECT id, password FROM users where user_email = ?`
|
||||
).bind(email).first() || {};
|
||||
|
||||
@@ -272,6 +272,12 @@ export const sendAdminInternalMail = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const isGlobalTurnstileEnabled = (c: Context<HonoCustomType>): boolean => {
|
||||
return getBooleanValue(c.env.ENABLE_GLOBAL_TURNSTILE_CHECK)
|
||||
&& !!c.env.CF_TURNSTILE_SITE_KEY
|
||||
&& !!c.env.CF_TURNSTILE_SECRET_KEY;
|
||||
}
|
||||
|
||||
export const checkCfTurnstile = async (
|
||||
c: Context<HonoCustomType>, token: string | undefined | null
|
||||
): Promise<void> => {
|
||||
@@ -369,6 +375,7 @@ export default {
|
||||
checkIsAdmin,
|
||||
getEnvStringList,
|
||||
sendAdminInternalMail,
|
||||
isGlobalTurnstileEnabled,
|
||||
checkCfTurnstile,
|
||||
checkUserPassword,
|
||||
getJsonSetting,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { jwt } from 'hono/jwt'
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import { api as commonApi } from './commom_api';
|
||||
import { api as openAuthApi } from './open_api/auth';
|
||||
import { api as mailsApi } from './mails_api'
|
||||
import { api as userApi } from './user_api';
|
||||
import { api as adminApi } from './admin_api';
|
||||
@@ -253,6 +254,7 @@ app.use('/admin/*', async (c, next) => {
|
||||
|
||||
|
||||
app.route('/', commonApi)
|
||||
app.route('/', openAuthApi)
|
||||
app.route('/', mailsApi)
|
||||
app.route('/', userApi)
|
||||
app.route('/', adminApi)
|
||||
|
||||
@@ -85,12 +85,16 @@ ENABLE_AUTO_REPLY = false
|
||||
# Turnstile verification
|
||||
# CF_TURNSTILE_SITE_KEY = ""
|
||||
# CF_TURNSTILE_SECRET_KEY = ""
|
||||
# Enable global Turnstile check for all login forms (requires Turnstile keys above)
|
||||
# ENABLE_GLOBAL_TURNSTILE_CHECK = true
|
||||
# telegram bot
|
||||
# TG_MAX_ADDRESS = 5
|
||||
# telegram bot info, predefined bot info can reduce latency of the webhook
|
||||
# TG_BOT_INFO = "{}"
|
||||
# allow user to switch language via /lang command
|
||||
# TG_ALLOW_USER_LANG = true
|
||||
# enable sending email attachments via Telegram push (50MB per file limit)
|
||||
# ENABLE_TG_PUSH_ATTACHMENT = true
|
||||
# global forward address list, if set, all emails will be forwarded to these addresses
|
||||
# FORWARD_ADDRESS_LIST = ["xxx@xxx.com"]
|
||||
# subdomain forward address list, if set, subdomain emails will be forwarded to these addresses
|
||||
|
||||
Reference in New Issue
Block a user