mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-12 19:49:52 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f08d062b32 | ||
|
|
8885948291 | ||
|
|
7c6d0d7c8a | ||
|
|
53c35062c8 | ||
|
|
1a7cfb8c95 | ||
|
|
d2c940aa2c | ||
|
|
db93828a81 | ||
|
|
be1bf71a47 | ||
|
|
424991a165 | ||
|
|
c97a9a278b | ||
|
|
a45d01f9fd | ||
|
|
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) {
|
||||
|
||||
29
.github/workflows/backend_deploy.yaml
vendored
29
.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
|
||||
@@ -63,14 +67,17 @@ jobs:
|
||||
if [ "$debug_mode" = "true" ]; then
|
||||
pnpm run deploy
|
||||
else
|
||||
output=$(pnpm run deploy 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
code=$?
|
||||
echo "Command failed with exit code $code"
|
||||
exit $code
|
||||
if pnpm run deploy >/dev/null 2>&1; then
|
||||
echo "Deploy succeeded"
|
||||
else
|
||||
code=$?
|
||||
echo "Command failed with exit code $code"
|
||||
exit "$code"
|
||||
fi
|
||||
fi
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
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
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -2,11 +2,46 @@
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">🇨🇳 中文</a> |
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.4.0(main)
|
||||
## v1.5.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918)
|
||||
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912)
|
||||
- feat: |创建邮箱| 新增 `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 开关,并支持在管理后台单独控制创建邮箱 API 的子域名后缀匹配;开启后允许 `foo.example.com` 匹配基础域名 `example.com`
|
||||
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
||||
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
||||
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB),多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894)
|
||||
- feat: |邮件存储| 支持通过 `ENABLE_MAIL_GZIP` 变量启用 Gzip 压缩邮件存储(#823)
|
||||
- 启用前需先执行数据库迁移:`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema`,或调用接口 `POST /admin/db_migration`
|
||||
- 新邮件写入 `raw_blob`,兼容读取 `raw` / `raw_blob`;压缩与解压会增加 CPU 开销,建议付费 Worker Plan 再开启
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
|
||||
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900)
|
||||
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 新增创建邮箱子域名匹配测试,覆盖默认精确匹配、后台开启后生效,以及 env=false 的硬禁用优先级
|
||||
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |创建邮箱| 补充创建邮箱 API / Worker 变量 / 子域名文档,说明“直接指定子域名”和“随机子域名”两种能力的区别
|
||||
- docs: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910)
|
||||
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769)
|
||||
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
|
||||
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -2,11 +2,46 @@
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">🇨🇳 中文</a> |
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.4.0(main)
|
||||
## v1.5.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Admin account list now supports column sorting (ID, name, created at, updated at, mail count, send count), search automatically resets pagination to page 1 (#918)
|
||||
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
|
||||
- feat: |Create Address| Add `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` switch and an admin-panel toggle for suffix-based subdomain matching in create-address APIs; when enabled, `foo.example.com` can match base domain `example.com`
|
||||
- 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)
|
||||
- feat: |Mail Storage| Support enabling gzip-compressed email storage via `ENABLE_MAIL_GZIP` variable (#823)
|
||||
- Run database migration before enabling it: `Admin -> Quick Setup -> Database -> Migrate Database`, or call `POST /admin/db_migration`
|
||||
- New emails are stored in `raw_blob` and reads stay compatible with `raw` / `raw_blob`; compression and decompression add CPU overhead, so a paid Worker plan is recommended
|
||||
|
||||
### 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 create-address subdomain matching tests covering default exact-match behavior, admin-enabled matching, and env=false hard-disable precedence
|
||||
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |Create Address| Update create-address API, worker variables, and subdomain docs to clarify the difference between explicitly specified subdomains and random subdomains
|
||||
- docs: |API| Add clarification between Address JWT and User JWT to avoid confusion; reorganize documentation menu structure with dedicated API Endpoints section (#910)
|
||||
- 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).
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
|
||||
- [x] 使用 `rust wasm` 解析邮件,解析速度快,几乎所有邮件都能解析,node 的解析模块解析邮件失败的邮件,rust wasm 也能解析成功
|
||||
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
- [x] 支持为指定基础域名创建随机二级域名邮箱地址,更适合收件隔离场景
|
||||
- [x] 支持发送邮件,支持 `DKIM` 验证
|
||||
- [x] 支持 `SMTP` 和 `Resend` 等多种发送方式
|
||||
- [x] 增加查看 `附件` 功能,支持附件图片显示
|
||||
|
||||
@@ -109,6 +109,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
- [x] Use `rust wasm` to parse emails, with fast parsing speed. Almost all emails can be parsed. Even emails that Node.js parsing modules fail to parse can be successfully parsed by rust wasm
|
||||
- [x] **AI Email Recognition** - Use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
|
||||
- [x] Support optional random second-level subdomain mailbox creation for selected base domains
|
||||
- [x] Support sending emails with `DKIM` verification
|
||||
- [x] Support multiple sending methods such as `SMTP` and `Resend`
|
||||
- [x] Add attachment viewing feature with support for displaying attachment images
|
||||
|
||||
2
db/2026-04-03-raw-blob.sql
Normal file
2
db/2026-04-03-raw-blob.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add raw_blob BLOB column for gzip-compressed email storage
|
||||
ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;
|
||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
raw_blob BLOB,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falli
|
||||
|
||||
COPY worker/src/ src/
|
||||
COPY worker/tsconfig.json ./
|
||||
COPY e2e/fixtures/wrangler.toml.e2e wrangler.toml
|
||||
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
||||
COPY ${WRANGLER_TOML} wrangler.toml
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
|
||||
@@ -20,6 +20,58 @@ services:
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-subdomain:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8789:8789"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8789", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8789/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-env-off:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8790:8790"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
|
||||
volumes:
|
||||
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8790/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-gzip:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
args:
|
||||
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.gzip
|
||||
ports:
|
||||
- "8788:8788"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8788", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8788/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
@@ -73,7 +125,10 @@ services:
|
||||
dockerfile: e2e/Dockerfile.e2e
|
||||
environment:
|
||||
WORKER_URL: http://worker:8787
|
||||
FRONTEND_URL: http://frontend:5173
|
||||
WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789
|
||||
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
||||
WORKER_GZIP_URL: http://worker-gzip:8788
|
||||
FRONTEND_URL: https://frontend:5173
|
||||
MAILPIT_API: http://mailpit:8025/api
|
||||
SMTP_PROXY_HOST: smtp-proxy
|
||||
SMTP_PROXY_SMTP_PORT: "8025"
|
||||
@@ -85,6 +140,12 @@ services:
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
worker-subdomain:
|
||||
condition: service_healthy
|
||||
worker-env-off:
|
||||
condition: service_healthy
|
||||
worker-gzip:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
smtp-proxy:
|
||||
|
||||
@@ -2,6 +2,9 @@ import { APIRequestContext } from '@playwright/test';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export const WORKER_URL = process.env.WORKER_URL!;
|
||||
export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
|
||||
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
||||
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||
export const TEST_DOMAIN = 'test.example.com';
|
||||
@@ -16,7 +19,7 @@ export async function createTestAddress(
|
||||
ctx: APIRequestContext,
|
||||
name: string,
|
||||
domain: string = TEST_DOMAIN
|
||||
): Promise<{ jwt: string; address: string }> {
|
||||
): Promise<{ jwt: string; address: string; address_id: number }> {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: uniqueName, domain },
|
||||
@@ -25,7 +28,7 @@ export async function createTestAddress(
|
||||
throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return { jwt: body.jwt, address: body.address };
|
||||
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_env_off"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
|
||||
JWT_SECRET = "e2e-test-secret-key-env-off"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
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 = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-env-off"
|
||||
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_gzip"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
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
|
||||
ENABLE_MAIL_GZIP = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-gzip"
|
||||
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
||||
18
e2e/package-lock.json
generated
18
e2e/package-lock.json
generated
@@ -6,8 +6,8 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.2.12",
|
||||
"nodemailer": "^8.0.1"
|
||||
"imapflow": "^1.2.18",
|
||||
"nodemailer": "^8.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
@@ -129,9 +129,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.12.tgz",
|
||||
"integrity": "sha512-UX8qCKXZk2xExe/x8KPTSbhROdtUGP13bSLSjT9Sb3YwGuryD4aFNlGhbWBW5B1GtgHMRxVv9yvl61RqXgIQtQ==",
|
||||
"version": "1.2.18",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.18.tgz",
|
||||
"integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
@@ -140,7 +140,7 @@
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.1",
|
||||
"nodemailer": "8.0.4",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.7"
|
||||
}
|
||||
@@ -191,9 +191,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.2.12",
|
||||
"nodemailer": "^8.0.1"
|
||||
"imapflow": "^1.2.18",
|
||||
"nodemailer": "^8.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const WORKER_BASE = process.env.WORKER_URL!;
|
||||
const WORKER_GZIP_BASE = process.env.WORKER_GZIP_URL || '';
|
||||
const FRONTEND_BASE = process.env.FRONTEND_URL!;
|
||||
|
||||
export default defineConfig({
|
||||
@@ -16,6 +17,13 @@ export default defineConfig({
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'api-gzip',
|
||||
testDir: './tests/api-gzip',
|
||||
use: {
|
||||
baseURL: WORKER_GZIP_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtp-proxy',
|
||||
testDir: './tests/smtp-proxy',
|
||||
@@ -29,6 +37,8 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: FRONTEND_BASE,
|
||||
...devices['Desktop Chrome'],
|
||||
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -14,9 +14,54 @@ for i in $(seq 1 60); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||
echo "==> Waiting for subdomain worker at $WORKER_URL_SUBDOMAIN ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL_SUBDOMAIN/health_check" > /dev/null 2>&1; then
|
||||
echo " Subdomain worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Subdomain worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||
echo "==> Waiting for env-off worker at $WORKER_URL_ENV_OFF ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL_ENV_OFF/health_check" > /dev/null 2>&1; then
|
||||
echo " Env-off worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Env-off worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
echo "==> Waiting for worker-gzip at $WORKER_GZIP_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_GZIP_URL/health_check" > /dev/null 2>&1; then
|
||||
echo " Worker-gzip ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Worker-gzip not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
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
|
||||
@@ -44,5 +89,26 @@ curl -sf -X POST "$WORKER_URL/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null
|
||||
echo " Database initialized"
|
||||
|
||||
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||
echo "==> Initializing subdomain worker database"
|
||||
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_migration" > /dev/null
|
||||
echo " Subdomain worker database initialized"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||
echo "==> Initializing env-off worker database"
|
||||
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_migration" > /dev/null
|
||||
echo " Env-off database initialized"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
echo "==> Initializing gzip worker database"
|
||||
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_migration" > /dev/null
|
||||
echo " Gzip worker database initialized"
|
||||
fi
|
||||
|
||||
echo "==> Running Playwright tests"
|
||||
exec npx playwright test "$@"
|
||||
|
||||
242
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
242
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_GZIP_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
/**
|
||||
* These tests run against a worker instance with ENABLE_MAIL_GZIP=true.
|
||||
* They verify gzip-compressed storage and backward-compatible reading.
|
||||
*/
|
||||
|
||||
// Helper: create address on the gzip worker
|
||||
async function createGzipAddress(ctx: any, name: string) {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/api/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
|
||||
}
|
||||
|
||||
// Helper: seed mail via receiveMail (goes through email() handler → gzip compression)
|
||||
async function receiveGzipMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; html?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const boundary = `----E2E${Date.now()}`;
|
||||
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
|
||||
const textPart = 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: multipart/alternative; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
textPart,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/html; charset=utf-8`,
|
||||
``,
|
||||
htmlPart,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_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()}`);
|
||||
const body = await res.json();
|
||||
if (!body.success) throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
|
||||
}
|
||||
|
||||
// Helper: seed mail via seedMail (direct INSERT → plaintext raw, no gzip)
|
||||
async function seedPlaintextMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Plaintext Mail';
|
||||
const messageId = `<e2e-plain-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
opts.text || 'Hello plaintext from E2E',
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
|
||||
data: { address, source: from, raw, message_id: messageId },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Helper: delete address on gzip worker
|
||||
async function deleteGzipAddress(ctx: any, jwt: string) {
|
||||
await ctx.delete(`${WORKER_GZIP_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Mail Gzip Storage', () => {
|
||||
test.beforeEach(() => {
|
||||
test.skip(!WORKER_GZIP_URL, 'WORKER_GZIP_URL not set — skipping gzip tests');
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-list');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip List Test',
|
||||
text: 'compressed content here',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].raw).toContain('Gzip List Test');
|
||||
expect(results[0].raw).toContain('compressed content here');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in detail', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-detail');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip Detail Test',
|
||||
html: '<b>bold gzip</b>',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${mailId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.raw).toContain('Gzip Detail Test');
|
||||
expect(mail.raw).toContain('<b>bold gzip</b>');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
|
||||
try {
|
||||
// 1. Direct INSERT plaintext (simulates pre-gzip data)
|
||||
await seedPlaintextMail(request, address, {
|
||||
subject: 'Old Plaintext Mail',
|
||||
text: 'legacy plain content',
|
||||
});
|
||||
|
||||
// 2. receiveMail → goes through email() handler → gzip compressed
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'New Gzip Mail',
|
||||
text: 'new compressed content',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
// Both mails should have readable raw content
|
||||
const subjects = results.map((r: any) => r.raw);
|
||||
expect(subjects.some((r: string) => r.includes('Old Plaintext Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('New Gzip Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('legacy plain content'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('new compressed content'))).toBe(true);
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin internal mail (sendAdminInternalMail) is gzip-compressed and readable', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-mail');
|
||||
try {
|
||||
// 1. Request send access → creates address_sender row
|
||||
const reqAccessRes = await request.post(`${WORKER_GZIP_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(reqAccessRes.ok()).toBe(true);
|
||||
|
||||
// 2. Get address_sender id
|
||||
const senderListRes = await request.get(
|
||||
`${WORKER_GZIP_URL}/admin/address_sender?limit=10&offset=0&address=${encodeURIComponent(address)}`,
|
||||
);
|
||||
expect(senderListRes.ok()).toBe(true);
|
||||
const senderList = await senderListRes.json();
|
||||
expect(senderList.results.length).toBeGreaterThanOrEqual(1);
|
||||
const senderId = senderList.results[0].id;
|
||||
|
||||
// 3. Update send access via admin API → triggers sendAdminInternalMail
|
||||
const updateRes = await request.post(`${WORKER_GZIP_URL}/admin/address_sender`, {
|
||||
data: { address, address_id: senderId, balance: 99, enabled: true },
|
||||
});
|
||||
expect(updateRes.ok()).toBe(true);
|
||||
|
||||
// 4. Verify the internal mail is readable
|
||||
const mailsRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const { results } = await mailsRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// mimetext base64-encodes the Subject header, so match on body content instead
|
||||
const internalMail = results.find((m: any) => m.raw?.includes('balance: 99'));
|
||||
expect(internalMail).toBeDefined();
|
||||
expect(internalMail.raw).toContain('admin@internal');
|
||||
expect(internalMail.raw).toContain('balance: 99');
|
||||
expect(internalMail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('raw_blob field is not exposed in API response', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-noblob');
|
||||
try {
|
||||
await receiveGzipMail(request, address, { subject: 'No Blob Leak' });
|
||||
|
||||
// Check list response
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0]).not.toHaveProperty('raw_blob');
|
||||
|
||||
// Check detail response
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${results[0].id}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const mail = await detailRes.json();
|
||||
expect(mail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,10 @@ import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, requestSendA
|
||||
test.describe('Address Lifecycle', () => {
|
||||
test('create address, request send access, fetch settings, then delete', async ({ request }) => {
|
||||
// Create address
|
||||
const { jwt, address } = await createTestAddress(request, 'lifecycle-test');
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test');
|
||||
expect(address).toContain('@' + TEST_DOMAIN);
|
||||
expect(jwt).toBeTruthy();
|
||||
expect(address_id).toBeGreaterThan(0);
|
||||
|
||||
// Request send access (creates address_sender row with DEFAULT_SEND_BALANCE)
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
19
e2e/tests/api/admin-new-address.spec.ts
Normal file
19
e2e/tests/api/admin-new-address.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Admin New Address', () => {
|
||||
test('should return address_id in response', async ({ request }) => {
|
||||
const uniqueName = `admin-test${Date.now()}`;
|
||||
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toContain('@' + TEST_DOMAIN);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
expect(typeof body.address_id).toBe('number');
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
198
e2e/tests/api/subdomain-create.spec.ts
Normal file
198
e2e/tests/api/subdomain-create.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
|
||||
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||
|
||||
async function getAccountSettings(request: any, workerUrl: string) {
|
||||
const res = await request.get(`${workerUrl}/admin/account_settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function buildAccountSettingsPayload(
|
||||
current: any,
|
||||
addressCreationSettings?: { enableSubdomainMatch?: boolean | null },
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
blockList: current.blockList || [],
|
||||
sendBlockList: current.sendBlockList || [],
|
||||
verifiedAddressList: current.verifiedAddressList || [],
|
||||
fromBlockList: current.fromBlockList || [],
|
||||
noLimitSendAddressList: current.noLimitSendAddressList || [],
|
||||
emailRuleSettings: current.emailRuleSettings || {},
|
||||
...(typeof addressCreationSettings !== 'undefined'
|
||||
? { addressCreationSettings }
|
||||
: {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
enableSubdomainMatch: boolean | null
|
||||
) {
|
||||
const current = await getAccountSettings(request, workerUrl);
|
||||
const res = await request.post(`${workerUrl}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(current, {
|
||||
enableSubdomainMatch,
|
||||
}),
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function restoreSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
originalValue: boolean | undefined
|
||||
) {
|
||||
if (typeof originalValue === 'boolean') {
|
||||
await saveSubdomainMatchSetting(request, workerUrl, originalValue);
|
||||
return;
|
||||
}
|
||||
await saveSubdomainMatchSetting(request, workerUrl, null);
|
||||
}
|
||||
|
||||
test.describe('Create Address Subdomain Match', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const createAddressSettings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
originalCreateAddressStoredEnabled = createAddressSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
const envOffSettings = await getAccountSettings(request, WORKER_URL_ENV_OFF);
|
||||
originalEnvOffStoredEnabled = envOffSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await restoreSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, originalCreateAddressStoredEnabled);
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
await restoreSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, originalEnvOffStoredEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin can clear override and return to env fallback', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, null);
|
||||
|
||||
const settings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(settings.addressCreationSubdomainMatchStatus?.storedEnabled).toBeUndefined();
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subenvfb${Date.now()}`, domain: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('invalid addressCreationSettings payload does not partially persist earlier settings', async ({ request }) => {
|
||||
const current = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
const uniqueBlockedKeyword = `should-not-persist-${Date.now()}`;
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(
|
||||
current,
|
||||
{ enableSubdomainMatch: 'invalid-value' as any },
|
||||
{
|
||||
blockList: [...(current.blockList || []), uniqueBlockedKeyword],
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const after = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(after.blockList || []).toEqual(current.blockList || []);
|
||||
expect(after.addressCreationSubdomainMatchStatus?.storedEnabled).toBe(
|
||||
current.addressCreationSubdomainMatchStatus?.storedEnabled
|
||||
);
|
||||
});
|
||||
|
||||
test('persisted false still keeps exact match only', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
|
||||
const adminName = `subdomain-admin-${Date.now()}`;
|
||||
const adminRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: adminName, domain: SUBDOMAIN },
|
||||
});
|
||||
expect(adminRes.ok()).toBe(true);
|
||||
const adminBody = await adminRes.json();
|
||||
expect(adminBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
expect(adminBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const userName = `subdomain-user-${Date.now()}`;
|
||||
const userRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name: userName, domain: NESTED_SUBDOMAIN },
|
||||
});
|
||||
expect(userRes.ok()).toBe(true);
|
||||
const userBody = await userRes.json();
|
||||
expect(userBody.address).toContain(`@${NESTED_SUBDOMAIN}`);
|
||||
expect(userBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const mixedCaseRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subcase${Date.now()}`, domain: MIXED_CASE_SUBDOMAIN },
|
||||
});
|
||||
expect(mixedCaseRes.ok()).toBe(true);
|
||||
const mixedCaseBody = await mixedCaseRes.json();
|
||||
expect(mixedCaseBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
|
||||
const invalidRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subinvalid${Date.now()}`, domain: INVALID_LOOKALIKE_DOMAIN },
|
||||
});
|
||||
expect(invalidRes.ok()).toBe(false);
|
||||
expect(await invalidRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyPrefixRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subempty${Date.now()}`, domain: INVALID_EMPTY_PREFIX_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyPrefixRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyPrefixRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyLabelRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublabel${Date.now()}`, domain: INVALID_EMPTY_LABEL_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyLabelRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyLabelRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidOverlongRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublong${Date.now()}`, domain: INVALID_OVERLONG_DOMAIN },
|
||||
});
|
||||
expect(invalidOverlongRes.ok()).toBe(false);
|
||||
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
});
|
||||
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
@@ -74,6 +74,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
maxAddressLen: res["maxAddressLen"] || 30,
|
||||
needAuth: res["needAuth"] || false,
|
||||
defaultDomains: res["defaultDomains"] || [],
|
||||
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
|
||||
domains: res["domains"].map((domain, index) => {
|
||||
return {
|
||||
label: domainLabels.length > index ? domainLabels[index] : domain,
|
||||
@@ -93,6 +94,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) => ({
|
||||
|
||||
@@ -28,6 +28,8 @@ export const useGlobalState = createGlobalState(
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
defaultDomains: [],
|
||||
/** @type {string[]} */
|
||||
randomSubdomainDomains: [],
|
||||
/** @type {Array<{label: string, value: string}>} */
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
@@ -39,6 +41,7 @@ export const useGlobalState = createGlobalState(
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
statusUrl: '',
|
||||
enableGlobalTurnstileCheck: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -111,8 +114,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') }}
|
||||
|
||||
@@ -114,6 +114,8 @@ const selectedCount = computed(() => checkedRowKeys.value.length);
|
||||
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
|
||||
|
||||
const addressQuery = ref("")
|
||||
const sortBy = ref("")
|
||||
const sortOrder = ref("")
|
||||
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
@@ -290,10 +292,12 @@ const fetchData = async () => {
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
||||
+ (sortBy.value ? `&sort_by=${sortBy.value}` : "")
|
||||
+ (sortOrder.value ? `&sort_order=${sortOrder.value}` : "")
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
if (page.value === 1 || addressCount > 0) {
|
||||
count.value = addressCount ?? 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -301,29 +305,57 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const searchData = () => {
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSorterChange = (sorter) => {
|
||||
sortBy.value = sorter.columnKey || "";
|
||||
sortOrder.value = sorter.order || "";
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
type: 'selection'
|
||||
},
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
key: "id",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
key: "name",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
key: "created_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at"
|
||||
key: "updated_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('source_meta'),
|
||||
key: "source_meta",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'source_meta' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
const val = row.source_meta;
|
||||
if (!val) return '';
|
||||
@@ -342,6 +374,8 @@ const columns = [
|
||||
{
|
||||
title: t('mail_count'),
|
||||
key: "mail_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -368,6 +402,8 @@ const columns = [
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -497,7 +533,7 @@ const columns = [
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
@@ -560,8 +596,8 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-input-group style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
|
||||
@keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
@keydown.enter="searchData" />
|
||||
<n-button @click="searchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
@@ -605,7 +641,7 @@ onMounted(async () => {
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
|
||||
:row-key="row => row.id" embedded />
|
||||
:row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded />
|
||||
</div>
|
||||
|
||||
<!-- Multi-action progress modal -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, h } from 'vue';
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
|
||||
|
||||
@@ -46,6 +46,14 @@ const { t } = useI18n({
|
||||
regex_invalid: 'Invalid regex pattern',
|
||||
forward_address_required: 'Forward address is required',
|
||||
rule_index: 'Rule',
|
||||
create_address_subdomain_match: 'Allow Subdomain Suffix Match When Creating Address',
|
||||
create_address_subdomain_match_tip: 'Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.',
|
||||
create_address_subdomain_match_note: 'This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.',
|
||||
create_address_subdomain_match_follow_env: 'Follow Environment Variable',
|
||||
create_address_subdomain_match_force_enable: 'Force Enable',
|
||||
create_address_subdomain_match_force_disable: 'Force Disable',
|
||||
create_address_subdomain_match_follow_env_note: 'Choosing "Follow Environment Variable" clears the admin override and returns to the unset state. The effective result is still controlled by the Worker env and the precedence rules.',
|
||||
create_address_subdomain_match_env_locked: 'Worker env ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH is currently false. The saved admin switch can be modified, but it will not take effect until env is enabled or removed.',
|
||||
},
|
||||
zh: {
|
||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||
@@ -82,6 +90,14 @@ const { t } = useI18n({
|
||||
regex_invalid: '无效的正则表达式',
|
||||
forward_address_required: '转发地址不能为空',
|
||||
rule_index: '规则',
|
||||
create_address_subdomain_match: '创建邮箱时允许子域名后缀匹配',
|
||||
create_address_subdomain_match_tip: '仅影响 /api/new_address 和 /admin/new_address 的域名校验。例如开启后,foo.example.com 可以匹配已配置的基础域名 example.com。',
|
||||
create_address_subdomain_match_note: '这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名;随机子域名功能只是在创建时自动补一个随机子域名。',
|
||||
create_address_subdomain_match_follow_env: '跟随环境变量',
|
||||
create_address_subdomain_match_force_enable: '强制开启',
|
||||
create_address_subdomain_match_force_disable: '强制关闭',
|
||||
create_address_subdomain_match_follow_env_note: '选择“跟随环境变量”会清空后台覆盖,恢复为未设置状态;最终是否开启仍由 Worker env 和优先级规则决定。',
|
||||
create_address_subdomain_match_env_locked: '当前 Worker 环境变量 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH 为 false。后台开关仍可保存,但在 env 打开或移除前不会生效。',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -95,6 +111,38 @@ const emailRuleSettings = ref({
|
||||
blockReceiveUnknowAddressEmail: false,
|
||||
emailForwardingList: []
|
||||
})
|
||||
const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
|
||||
FOLLOW_ENV: 'follow_env',
|
||||
FORCE_ENABLE: 'force_enable',
|
||||
FORCE_DISABLE: 'force_disable'
|
||||
}
|
||||
const addressCreationSubdomainMatchMode = ref(ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV)
|
||||
const addressCreationSubdomainMatchStatus = ref({
|
||||
envConfigured: false,
|
||||
envEnabled: false,
|
||||
storedEnabled: undefined,
|
||||
effectiveEnabled: false
|
||||
})
|
||||
const subdomainMatchEnvLocked = computed(() => {
|
||||
return addressCreationSubdomainMatchStatus.value.envConfigured
|
||||
&& !addressCreationSubdomainMatchStatus.value.envEnabled
|
||||
})
|
||||
const subdomainMatchModeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV,
|
||||
label: t('create_address_subdomain_match_follow_env')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE,
|
||||
label: t('create_address_subdomain_match_force_enable')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE,
|
||||
label: t('create_address_subdomain_match_force_disable')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const showEmailForwardingModal = ref(false)
|
||||
const emailForwardingList = ref([])
|
||||
@@ -246,8 +294,27 @@ const saveEmailForwardingConfig = () => {
|
||||
showEmailForwardingModal.value = false
|
||||
}
|
||||
|
||||
const getSubdomainMatchModeByStoredValue = (storedEnabled) => {
|
||||
if (storedEnabled === true) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE
|
||||
}
|
||||
if (storedEnabled === false) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE
|
||||
}
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
const getSubdomainMatchPayloadValue = (mode) => {
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE) {
|
||||
return true
|
||||
}
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fetchData = async ({ suppressErrorMessage = false } = {}) => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
@@ -259,33 +326,63 @@ const fetchData = async () => {
|
||||
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
|
||||
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
|
||||
}
|
||||
addressCreationSubdomainMatchStatus.value = {
|
||||
envConfigured: !!res.addressCreationSubdomainMatchStatus?.envConfigured,
|
||||
envEnabled: !!res.addressCreationSubdomainMatchStatus?.envEnabled,
|
||||
storedEnabled: typeof res.addressCreationSubdomainMatchStatus?.storedEnabled === 'boolean'
|
||||
? res.addressCreationSubdomainMatchStatus.storedEnabled
|
||||
: undefined,
|
||||
effectiveEnabled: !!res.addressCreationSubdomainMatchStatus?.effectiveEnabled
|
||||
}
|
||||
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
|
||||
addressCreationSubdomainMatchStatus.value.storedEnabled
|
||||
)
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
if (!suppressErrorMessage) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||
emailRuleSettings: emailRuleSettings.value,
|
||||
addressCreationSettings: {
|
||||
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
|
||||
}
|
||||
}
|
||||
await api.fetch(`/admin/account_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||
emailRuleSettings: emailRuleSettings.value,
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchData({ suppressErrorMessage: true })
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh account settings after save', error)
|
||||
message.warning(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
try {
|
||||
await fetchData();
|
||||
} catch {
|
||||
// 首次加载失败时,错误提示已经在 fetchData 内部统一处理,这里无需重复提示。
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -352,6 +449,29 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('block_receive_unknow_address_email')">
|
||||
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('create_address_subdomain_match')">
|
||||
<n-flex vertical style="width: 100%;">
|
||||
<n-radio-group v-model:value="addressCreationSubdomainMatchMode">
|
||||
<n-space vertical size="small">
|
||||
<n-radio v-for="item in subdomainMatchModeOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_tip') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_note') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_follow_env_note') }}
|
||||
</n-text>
|
||||
<n-alert v-if="subdomainMatchEnvLocked" type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('create_address_subdomain_match_env_locked') }}
|
||||
</n-alert>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('email_forwarding_config')">
|
||||
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
|
||||
</n-form-item-row>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
@@ -22,6 +22,8 @@ const { t } = useI18n({
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
addressPassword: 'Address Password',
|
||||
linkWithAddressCredential: 'Open to auto login email link',
|
||||
enableRandomSubdomain: 'Use Random Subdomain',
|
||||
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
@@ -33,11 +35,14 @@ const { t } = useI18n({
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
addressPassword: '地址密码',
|
||||
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
|
||||
enableRandomSubdomain: '启用随机子域名',
|
||||
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
@@ -45,6 +50,19 @@ const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
}
|
||||
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value)
|
||||
})
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const newEmail = async () => {
|
||||
if (!emailName.value || !emailDomain.value) {
|
||||
message.error(t('fillInAllFields'))
|
||||
@@ -55,6 +73,7 @@ const newEmail = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
enableRandomSubdomain: enableRandomSubdomain.value,
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
})
|
||||
@@ -119,6 +138,14 @@ onMounted(async () => {
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
{{ t('creatNewEmail') }}
|
||||
</n-button>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
||||
@@ -19,13 +19,14 @@ const props = defineProps({
|
||||
},
|
||||
newAddressPath: {
|
||||
type: Function,
|
||||
default: async (address_name, domain, cf_token) => {
|
||||
default: async (address_name, domain, cf_token, enableRandomSubdomain) => {
|
||||
return await api.fetch("/api/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: address_name,
|
||||
domain: domain,
|
||||
cf_token: cf_token,
|
||||
enableRandomSubdomain: enableRandomSubdomain,
|
||||
}),
|
||||
});
|
||||
},
|
||||
@@ -47,6 +48,9 @@ const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const loginCfToken = ref("")
|
||||
const loginTurnstileRef = ref(null)
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
const loginAddress = ref('')
|
||||
const loginPassword = ref('')
|
||||
@@ -72,7 +76,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 +90,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -93,6 +99,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 +116,7 @@ const login = async () => {
|
||||
await router.push(getRouterPathWithLang("/", locale.value));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
loginTurnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +143,8 @@ const { locale, t } = useI18n({
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
emailPasswordRequired: 'Email and password are required',
|
||||
enableRandomSubdomain: 'Use Random Subdomain',
|
||||
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
|
||||
},
|
||||
zh: {
|
||||
login: '登录',
|
||||
@@ -151,6 +167,8 @@ const { locale, t } = useI18n({
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
emailPasswordRequired: '邮箱和密码不能为空',
|
||||
enableRandomSubdomain: '启用随机子域名',
|
||||
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -203,7 +221,8 @@ const newEmail = async () => {
|
||||
const res = await props.newAddressPath(
|
||||
nameToSend,
|
||||
emailDomain.value,
|
||||
cfToken.value
|
||||
cfToken.value,
|
||||
enableRandomSubdomain.value
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
@@ -229,6 +248,19 @@ const addressPrefix = computed(() => {
|
||||
return openSettings.value.prefix;
|
||||
});
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false;
|
||||
}
|
||||
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value);
|
||||
});
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const domainsOptions = computed(() => {
|
||||
// if user has role, return role domains
|
||||
if (userSettings.value.user_role) {
|
||||
@@ -289,6 +321,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'"
|
||||
@@ -335,6 +370,14 @@ onMounted(async () => {
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="domainsOptions" />
|
||||
</n-input-group>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||
<template #icon>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -52,13 +52,19 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const newAddressPath = async (address_name: string, domain: string, cf_token: string) => {
|
||||
const newAddressPath = async (
|
||||
address_name: string,
|
||||
domain: string,
|
||||
cf_token: string,
|
||||
enableRandomSubdomain: boolean
|
||||
) => {
|
||||
return await api.fetch("/telegram/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData,
|
||||
address: `${address_name}@${domain}`,
|
||||
cf_token: cf_token,
|
||||
enableRandomSubdomain,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -149,19 +149,26 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
items: [
|
||||
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
|
||||
{ text: 'Configure SMTP IMAP Proxy', link: 'feature/config-smtp-proxy' },
|
||||
{ text: 'Send Email API', link: 'feature/send-mail-api' },
|
||||
{ text: 'View Email API', link: 'feature/mail-api' },
|
||||
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
|
||||
{ text: 'Configure Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
|
||||
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
|
||||
{ text: 'Configure Webhook', link: 'feature/webhook' },
|
||||
{ text: 'New Address API', link: 'feature/new-address-api' },
|
||||
{ text: 'OAuth2 Third-party Login', link: 'feature/user-oauth2' },
|
||||
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
|
||||
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'API Endpoints',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'New Address API', link: 'feature/new-address-api' },
|
||||
{ text: 'View Email API', link: 'feature/mail-api' },
|
||||
{ text: 'Send Email API', link: 'feature/send-mail-api' },
|
||||
{ text: 'Delete Address API', link: 'feature/delete-address' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'Feature Overview',
|
||||
collapsed: false,
|
||||
|
||||
@@ -149,19 +149,26 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
items: [
|
||||
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
|
||||
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
{ text: '查看邮件 API', link: 'feature/mail-api' },
|
||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
|
||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
|
||||
{ text: '配置 webhook', link: 'feature/webhook' },
|
||||
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
|
||||
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
|
||||
{ text: '配置其他worker增强', link: 'feature/another-worker-enhanced' },
|
||||
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: 'API 接口',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
|
||||
{ text: '查看邮件 API', link: 'feature/mail-api' },
|
||||
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
|
||||
{ text: '删除邮箱地址 API', link: 'feature/delete-address' },
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '功能简介',
|
||||
collapsed: false,
|
||||
|
||||
42
vitepress-docs/docs/en/guide/feature/delete-address.md
Normal file
42
vitepress-docs/docs/en/guide/feature/delete-address.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Delete Address API
|
||||
|
||||
## Admin Delete Address API
|
||||
|
||||
Delete an address by address ID. This endpoint requires admin auth and deletes related data (mails, sender settings, bindings, etc.).
|
||||
|
||||
```bash
|
||||
DELETE /admin/delete_address/:id
|
||||
```
|
||||
|
||||
Header:
|
||||
|
||||
- `x-admin-auth: <admin_password>`
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
## User Delete Address API
|
||||
|
||||
Delete mailbox by address JWT. The request needs address token permission and deletes related data (received mails, sent items, auto reply data, sender bindings, user bindings, telegram bind records).
|
||||
|
||||
```bash
|
||||
DELETE /api/delete_address
|
||||
```
|
||||
|
||||
Headers:
|
||||
|
||||
- `Authorization: Bearer <address_jwt>`
|
||||
|
||||
Notes:
|
||||
|
||||
- `ENABLE_USER_DELETE_EMAIL` must be enabled.
|
||||
- Address credential can be obtained from `/api/new_address` or `/admin/new_address`.
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
@@ -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
|
||||
@@ -127,6 +131,14 @@ print(response.json())
|
||||
|
||||
## User Mail API
|
||||
|
||||
::: warning Note: User JWT vs Address JWT
|
||||
This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/register`), with `x-user-token` header.
|
||||
|
||||
**Do not confuse with Address JWT**:
|
||||
- Address JWT uses `Authorization: Bearer <jwt>` to access `/api/*` endpoints
|
||||
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
|
||||
:::
|
||||
|
||||
Supports `address` filter
|
||||
|
||||
```python
|
||||
@@ -151,4 +163,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.
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Create New Email Address API
|
||||
|
||||
::: warning Note: Address JWT vs User JWT
|
||||
This page describes **Address JWT**, which is different from **User JWT**:
|
||||
|
||||
- **Address JWT**: Returned when creating a mailbox via `/api/new_address` or `/admin/new_address`
|
||||
- Use `Authorization: Bearer <jwt>` header
|
||||
- Access `/api/*` endpoints (view mails, delete mails, etc.)
|
||||
|
||||
- **User JWT**: Obtained via `/user_api/login` or `/user_api/register`
|
||||
- Use `x-user-token: <jwt>` header
|
||||
- Access `/user_api/*` endpoints (user account management)
|
||||
|
||||
**Do not confuse these two JWT types!**
|
||||
:::
|
||||
|
||||
## Create Email Address via Admin API
|
||||
|
||||
This is a `python` example using the `requests` library to send emails.
|
||||
@@ -25,6 +39,32 @@ res = requests.post(
|
||||
print(res.json())
|
||||
```
|
||||
|
||||
### Create a Subdomain Mailbox Address
|
||||
|
||||
If your base domain is already configured in `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`, and
|
||||
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is enabled (it can also be toggled in the admin panel),
|
||||
the create-address APIs can accept subdomains directly:
|
||||
|
||||
```python
|
||||
res = requests.post(
|
||||
"https://xxxx.xxxx/admin/new_address",
|
||||
json={
|
||||
"enablePrefix": True,
|
||||
"name": "project001",
|
||||
"domain": "team.example.com",
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<your_website_admin_password>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- If `example.com` is an allowed base domain, `team.example.com` and `dev.team.example.com` can match successfully
|
||||
- Lookalike domains such as `badexample.com` will **not** be treated as `example.com`
|
||||
- This is different from `RANDOM_SUBDOMAIN_DOMAINS`: here the caller **explicitly specifies** the subdomain, instead of the system generating a random one
|
||||
- In the admin panel, this can be set to **Follow Environment Variable / Force Enable / Force Disable**. Choosing **Follow Environment Variable** clears the admin override and returns to env fallback behavior.
|
||||
|
||||
## Batch Create Random Username Email Addresses API Example
|
||||
|
||||
### Batch Create Email Addresses via Admin API
|
||||
|
||||
@@ -9,3 +9,50 @@ Mail channel is no longer supported. The reference below is limited to the recei
|
||||
Reference
|
||||
|
||||
- [Configure Subdomain Email](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
|
||||
|
||||
## Create Random Second-level Subdomain Addresses
|
||||
|
||||
If your base domain mail routing is already configured, you can also let users create mailbox
|
||||
addresses with an automatically generated random second-level subdomain, for example:
|
||||
|
||||
- Base domain: `abc.com`
|
||||
- Created address: `name@x7k2p9q1.abc.com`
|
||||
|
||||
This is useful for mailbox isolation and reducing repeated hits on the same address.
|
||||
|
||||
Add these worker variables:
|
||||
|
||||
```toml
|
||||
RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||
RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
```
|
||||
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow optional random second-level subdomains
|
||||
- `RANDOM_SUBDOMAIN_LENGTH`: random string length, range `1-63`, default `8`
|
||||
|
||||
> [!NOTE]
|
||||
> This feature only appends a random second-level subdomain when the mailbox is created.
|
||||
>
|
||||
> It does not automatically create Cloudflare-side subdomain mail routes or DNS records for you,
|
||||
> so make sure the base-domain/subdomain routing is already available first.
|
||||
|
||||
## Let APIs Specify Subdomains Directly
|
||||
|
||||
If you do not want the system to generate a random subdomain, and instead want the caller to
|
||||
explicitly create addresses like `team.abc.com`, enable:
|
||||
|
||||
```toml
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
```
|
||||
|
||||
When this is enabled, as long as `abc.com` is in the allowed base-domain list, the following
|
||||
addresses can be created through `/api/new_address` or `/admin/new_address`:
|
||||
|
||||
- `name@team.abc.com`
|
||||
- `name@dev.team.abc.com`
|
||||
|
||||
> [!NOTE]
|
||||
> This only relaxes the domain validation used by the create-address APIs. It does not change the
|
||||
> default domain dropdown, and it does not create Cloudflare-side subdomain mail routes for you.
|
||||
>
|
||||
> If the admin panel has already saved an override once, you can switch it back to **Follow Environment Variable** to clear the override and return to env fallback behavior.
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -32,11 +32,33 @@
|
||||
| `ADDRESS_REGEX` | Text | Regular expression to replace illegal symbols in `email address` name, symbols not in the regex will be replaced. Default is `[^a-z0-9]` if not set. Use with caution as some symbols may prevent email reception | `[^a-z0-9]` |
|
||||
| `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` |
|
||||
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | Text/JSON | Whether to allow create-address APIs to use base-domain suffix matching. When enabled, if `example.com` is allowed, `/api/new_address` and `/admin/new_address` can also accept `foo.example.com` or `a.b.example.com` | `true` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow optional random subdomain creation, so `name@abc.com` can become `name@<random>.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` |
|
||||
| `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` |
|
||||
|
||||
> [!NOTE]
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` only controls automatic random subdomain generation during mailbox
|
||||
> creation. It does not create Cloudflare-side subdomain routing for you.
|
||||
>
|
||||
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
|
||||
> domain.
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is different from random subdomain generation: it lets
|
||||
> API callers **directly specify** a subdomain such as `foo.example.com`, while random subdomain
|
||||
> generation appends one automatically during creation.
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` precedence: if the env is explicitly set to `false`, the
|
||||
> feature is globally forced off; otherwise the persisted admin setting takes precedence, and the env
|
||||
> value is only used as a fallback when no admin setting has been saved.
|
||||
>
|
||||
> The admin panel exposes three explicit states: **Follow Environment Variable**, **Force Enable**,
|
||||
> and **Force Disable**. Saving **Follow Environment Variable** clears the admin override and returns
|
||||
> the feature to the "unset" fallback behavior.
|
||||
|
||||
## Email Reception Related Variables
|
||||
|
||||
| Variable Name | Type | Description | Example |
|
||||
@@ -48,8 +70,11 @@
|
||||
| `FORWARD_ADDRESS_LIST` | JSON | Global forward address list, disabled if not configured, all emails will be forwarded to listed addresses when enabled | `["xxx@xxx.com"]` |
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
|
||||
|
||||
> [!NOTE]
|
||||
> `ENABLE_MAIL_GZIP` adds CPU cost for gzip compression on write and decompression on read. Free-tier Workers are more likely to hit CPU limits, so a paid plan is recommended before enabling it
|
||||
>
|
||||
> `Junk mail checking` and `attachment removal` require email parsing, free tier CPU is limited, may cause large email parsing timeout
|
||||
>
|
||||
> If you want stronger email parsing capabilities
|
||||
@@ -101,8 +126,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 +137,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
|
||||
|
||||
42
vitepress-docs/docs/zh/guide/feature/delete-address.md
Normal file
42
vitepress-docs/docs/zh/guide/feature/delete-address.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 删除邮箱地址 API
|
||||
|
||||
## 管理员删除地址 API
|
||||
|
||||
使用地址 ID 删除邮箱地址。该接口需要管理员鉴权,并会同时清理关联数据(收件、发件来源授权、用户绑定等)。
|
||||
|
||||
```bash
|
||||
DELETE /admin/delete_address/:id
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
- `x-admin-auth: <admin_password>`
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
## 普通地址删除 API
|
||||
|
||||
使用地址 JWT 删除当前邮箱。该接口会清理关联数据(收件、发件、自动回复、sender 绑定、用户绑定、Telegram 绑定等)。
|
||||
|
||||
```bash
|
||||
DELETE /api/delete_address
|
||||
```
|
||||
|
||||
请求头:
|
||||
|
||||
- `Authorization: Bearer <address_jwt>`
|
||||
|
||||
说明:
|
||||
|
||||
- 需开启 `ENABLE_USER_DELETE_EMAIL = true`
|
||||
- 地址凭证来自 `/api/new_address` 或 `/admin/new_address`
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
@@ -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
|
||||
@@ -127,6 +131,14 @@ print(response.json())
|
||||
|
||||
## user 邮件 API
|
||||
|
||||
::: warning 注意:用户 JWT vs 地址 JWT
|
||||
此接口使用**用户 JWT**(通过 `/user_api/login` 或 `/user_api/register` 获得),使用 `x-user-token` header。
|
||||
|
||||
**请勿与地址 JWT 混淆**:
|
||||
- 地址 JWT 使用 `Authorization: Bearer <jwt>` 访问 `/api/*` 接口
|
||||
- 用户 JWT 使用 `x-user-token: <jwt>` 访问 `/user_api/*` 接口
|
||||
:::
|
||||
|
||||
支持 `address` 过滤
|
||||
|
||||
```python
|
||||
@@ -151,4 +163,6 @@ response = requests.get(url, headers=headers, params=querystring)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
**注意**:`/user_api/mails` 同样返回原始 RFC822 内容;请在客户端解析后提取 `subject`、`text`、`html`。
|
||||
|
||||
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 新建邮箱地址 API
|
||||
|
||||
::: warning 注意:地址 JWT vs 用户 JWT
|
||||
本页面介绍的是**地址 JWT**,与**用户 JWT** 是两种不同的认证方式:
|
||||
|
||||
- **地址 JWT**:通过 `/api/new_address` 或 `/admin/new_address` 创建邮箱时返回
|
||||
- 使用 `Authorization: Bearer <jwt>` header
|
||||
- 用于访问 `/api/*` 接口(查看邮件、删除邮件等)
|
||||
|
||||
- **用户 JWT**:通过 `/user_api/login` 或 `/user_api/register` 获得
|
||||
- 使用 `x-user-token: <jwt>` header
|
||||
- 用于访问 `/user_api/*` 接口(用户账户管理)
|
||||
|
||||
**请勿混淆两种 JWT 的使用方式!**
|
||||
:::
|
||||
|
||||
## 通过 admin API 新建邮箱地址
|
||||
|
||||
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
|
||||
@@ -25,6 +39,31 @@ res = requests.post(
|
||||
print(res.json())
|
||||
```
|
||||
|
||||
### 创建子域名邮箱地址
|
||||
|
||||
如果你已经把基础域名配置进 `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`,并且开启了
|
||||
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH`(管理后台也可单独开关),那么创建地址 API 可以直接接收子域名:
|
||||
|
||||
```python
|
||||
res = requests.post(
|
||||
"https://xxxx.xxxx/admin/new_address",
|
||||
json={
|
||||
"enablePrefix": True,
|
||||
"name": "project001",
|
||||
"domain": "team.example.com",
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<你的网站admin密码>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
- 如果允许域名里有 `example.com`,则 `team.example.com`、`dev.team.example.com` 都可以匹配成功
|
||||
- `badexample.com` 这种**不是点分后缀**的域名不会被误判为 `example.com`
|
||||
- 这与 `RANDOM_SUBDOMAIN_DOMAINS` 不同:这里是**由调用方显式指定子域名**,不是系统自动生成随机子域名
|
||||
- 管理后台可以把该能力设置为“跟随环境变量 / 强制开启 / 强制关闭”;其中“跟随环境变量”会清空后台覆盖,恢复到未设置后按 env 回退
|
||||
|
||||
## 批量创建随机用户名邮箱地址 API 示例
|
||||
|
||||
### 通过 admin API 批量新建邮箱地址
|
||||
|
||||
@@ -9,3 +9,49 @@ mail channel 已不被支持,下面参考中仅限收件部分。
|
||||
参考
|
||||
|
||||
- [配置子域名邮箱](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
|
||||
|
||||
## 创建随机二级域名地址
|
||||
|
||||
如果你已经配置好了基础域名的收件路由,还可以让用户在创建邮箱时,自动生成随机二级域名地址,例如:
|
||||
|
||||
- 基础域名:`abc.com`
|
||||
- 创建结果:`name@x7k2p9q1.abc.com`
|
||||
|
||||
这适合做收件隔离、降低地址被重复命中的概率。
|
||||
|
||||
在 `worker` 变量中增加:
|
||||
|
||||
```toml
|
||||
RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||
RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
```
|
||||
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`:允许启用随机二级域名的基础域名列表
|
||||
- `RANDOM_SUBDOMAIN_LENGTH`:随机串长度,范围 `1-63`,默认 `8`
|
||||
|
||||
> [!NOTE]
|
||||
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
|
||||
>
|
||||
> 它不会自动帮你创建 Cloudflare 侧的子域名收件路由或 DNS 配置,请先确保基础域名/子域名路由本身已经可用。
|
||||
|
||||
## 允许 API 直接指定子域名
|
||||
|
||||
如果你不想让系统随机生成子域名,而是希望调用方在创建地址时直接指定 `team.abc.com` 这种子域名,
|
||||
可以开启:
|
||||
|
||||
```toml
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
```
|
||||
|
||||
开启后,只要允许域名里包含基础域名 `abc.com`,那么:
|
||||
|
||||
- `name@team.abc.com`
|
||||
- `name@dev.team.abc.com`
|
||||
|
||||
都可以通过 `/api/new_address` 或 `/admin/new_address` 创建。
|
||||
|
||||
> [!NOTE]
|
||||
> 这个能力只放宽“创建地址 API 的域名校验”,不会改动默认域名下拉,也不会自动创建 Cloudflare 侧的
|
||||
> 子域名邮箱路由。
|
||||
>
|
||||
> 如果你在管理后台里保存过这个开关,后续也可以通过“跟随环境变量”把它恢复到未设置状态,再重新回退到 env 默认值。
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -32,11 +32,29 @@
|
||||
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
|
||||
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
|
||||
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | 文本/JSON | 是否允许创建邮箱 API 使用“基础域名后缀匹配”。开启后,如果允许域名里有 `example.com`,则 `/api/new_address` 与 `/admin/new_address` 可以接受 `foo.example.com`、`a.b.example.com` 这类子域名 | `true` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | 允许启用随机子域名的基础域名列表,启用后可把 `name@abc.com` 创建成 `name@随机串.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` |
|
||||
| `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` |
|
||||
|
||||
> [!NOTE]
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare
|
||||
> 侧的子域名路由。
|
||||
>
|
||||
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与随机子域名功能不同:它允许 API 调用方**直接指定**
|
||||
> `foo.example.com` 这类子域名;而随机子域名功能是系统在创建时自动补一个随机前缀。
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 的优先级为:当 env 明确设置为 `false` 时,全局硬禁用;
|
||||
> 其他情况下优先使用后台持久化设置,后台未设置时再回退到 env 值。
|
||||
>
|
||||
> 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择
|
||||
> “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。
|
||||
|
||||
## 接受邮件相关变量
|
||||
|
||||
| 变量名 | 类型 | 说明 | 示例 |
|
||||
@@ -48,8 +66,11 @@
|
||||
| `FORWARD_ADDRESS_LIST` | JSON | 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址 | `["xxx@xxx.com"]` |
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema` 或 `POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
|
||||
|
||||
> [!NOTE]
|
||||
> `ENABLE_MAIL_GZIP` 会增加邮件写入压缩与读取解压的 CPU 消耗,免费版 Worker 更容易触发 CPU 限制,建议付费 Plan 再开启
|
||||
>
|
||||
> `垃圾邮件检查` 和 `移除附件功能` 需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
|
||||
>
|
||||
> 如果你想解析邮件能力更强
|
||||
@@ -101,8 +122,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 +133,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
@@ -1,5 +1,5 @@
|
||||
import { Context } from "hono";
|
||||
import { handleListQuery } from "../common";
|
||||
import { handleMailListQuery } from "../common";
|
||||
|
||||
export default {
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
@@ -9,7 +9,7 @@ export default {
|
||||
const filterQuerys = [addressQuery].filter((item) => item).join(" and ");
|
||||
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
|
||||
const filterParams = [...addressParams]
|
||||
return await handleListQuery(c,
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails ${finalQuery}`,
|
||||
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,
|
||||
filterParams, limit, offset
|
||||
@@ -17,7 +17,7 @@ export default {
|
||||
},
|
||||
getUnknowMails: async (c: Context<HonoCustomType>) => {
|
||||
const { limit, offset } = c.req.query();
|
||||
return await handleListQuery(c,
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT * FROM raw_mails where address NOT IN (select name from address) `,
|
||||
`SELECT count(*) as count FROM raw_mails`
|
||||
+ ` where address NOT IN (select name from address) `,
|
||||
|
||||
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
raw_blob BLOB,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -184,6 +185,18 @@ export default {
|
||||
// migration to v0.0.6: add message_id index on raw_mails
|
||||
await c.env.DB.exec(`CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);`);
|
||||
}
|
||||
if (version && version <= "v0.0.6") {
|
||||
// migration to v0.0.7: add raw_blob column for gzip compressed email storage
|
||||
const tableInfo = await c.env.DB.prepare(
|
||||
`PRAGMA table_info(raw_mails)`
|
||||
).all();
|
||||
const hasRawBlob = tableInfo.results?.some(
|
||||
(col: any) => col.name === 'raw_blob'
|
||||
);
|
||||
if (!hasRawBlob) {
|
||||
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;`);
|
||||
}
|
||||
}
|
||||
if (version != CONSTANTS.DB_VERSION) {
|
||||
// remove all \r and \n characters from the query string
|
||||
// split by ; and join with a ;\n
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Jwt } from 'hono/utils/jwt'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { newAddress, handleListQuery, getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
|
||||
import { CONSTANTS } from '../constants'
|
||||
import cleanup_api from './cleanup_api'
|
||||
import admin_user_api from './admin_user_api'
|
||||
@@ -21,8 +21,60 @@ import e2e_test_api from './e2e_test_api'
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
const normalizeAddressCreationSettingsUpdate = (
|
||||
value: unknown
|
||||
): {
|
||||
shouldUpdate: boolean,
|
||||
shouldClear: boolean,
|
||||
nextEnableSubdomainMatch?: boolean,
|
||||
} | null => {
|
||||
if (typeof value === 'undefined') {
|
||||
return {
|
||||
shouldUpdate: false,
|
||||
shouldClear: false,
|
||||
};
|
||||
}
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
|
||||
if (typeof nextEnableSubdomainMatch === 'undefined') {
|
||||
return {
|
||||
shouldUpdate: false,
|
||||
shouldClear: false,
|
||||
};
|
||||
}
|
||||
// null 代表“清空后台覆盖,恢复为未设置并回退到 env”,这是给前端三态显式使用的正式路径。
|
||||
if (nextEnableSubdomainMatch === null) {
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: true,
|
||||
};
|
||||
}
|
||||
if (typeof nextEnableSubdomainMatch !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
shouldUpdate: true,
|
||||
shouldClear: false,
|
||||
nextEnableSubdomainMatch,
|
||||
};
|
||||
}
|
||||
|
||||
api.get('/admin/address', async (c) => {
|
||||
const { limit, offset, query } = c.req.query();
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
const allowedSortColumns: Record<string, string> = {
|
||||
'id': 'a.id',
|
||||
'name': 'a.name',
|
||||
'created_at': 'a.created_at',
|
||||
'updated_at': 'a.updated_at',
|
||||
'source_meta': 'a.source_meta',
|
||||
'mail_count': 'mail_count',
|
||||
'send_count': 'send_count',
|
||||
};
|
||||
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
|
||||
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
|
||||
const orderBy = `${sortColumn} ${sortDirection}`;
|
||||
if (query) {
|
||||
return await handleListQuery(c,
|
||||
`SELECT a.*,`
|
||||
@@ -31,7 +83,7 @@ api.get('/admin/address', async (c) => {
|
||||
+ ` FROM address a`
|
||||
+ ` where name like ?`,
|
||||
`SELECT count(*) as count FROM address where name like ?`,
|
||||
[`%${query}%`], limit, offset
|
||||
[`%${query}%`], limit, offset, orderBy
|
||||
);
|
||||
}
|
||||
return await handleListQuery(c,
|
||||
@@ -40,12 +92,12 @@ api.get('/admin/address', async (c) => {
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ ` FROM address a`,
|
||||
`SELECT count(*) as count FROM address`,
|
||||
[], limit, offset
|
||||
[], limit, offset, orderBy
|
||||
);
|
||||
})
|
||||
|
||||
api.post('/admin/new_address', async (c) => {
|
||||
const { name, domain, enablePrefix } = await c.req.json();
|
||||
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (!name) {
|
||||
return c.text(msgs.RequiredFieldMsg, 400)
|
||||
@@ -53,6 +105,7 @@ api.post('/admin/new_address', async (c) => {
|
||||
try {
|
||||
const res = await newAddress(c, {
|
||||
name, domain, enablePrefix,
|
||||
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
|
||||
checkLengthByConfig: false,
|
||||
addressPrefix: null,
|
||||
checkAllowDomains: false,
|
||||
@@ -281,13 +334,19 @@ api.get('/admin/account_settings', async (c) => {
|
||||
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
|
||||
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
|
||||
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
|
||||
const addressCreationSettings = await getAddressCreationSettings(c);
|
||||
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
|
||||
return c.json({
|
||||
blockList: blockList || [],
|
||||
sendBlockList: sendBlockList || [],
|
||||
verifiedAddressList: verifiedAddressList || [],
|
||||
fromBlockList: fromBlockList || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList || [],
|
||||
emailRuleSettings: emailRuleSettings || {}
|
||||
emailRuleSettings: emailRuleSettings || {},
|
||||
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
|
||||
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
|
||||
: {},
|
||||
addressCreationSubdomainMatchStatus,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -300,14 +359,22 @@ api.post('/admin/account_settings', async (c) => {
|
||||
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
|
||||
const {
|
||||
blockList, sendBlockList, noLimitSendAddressList,
|
||||
verifiedAddressList, fromBlockList, emailRuleSettings
|
||||
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings
|
||||
} = await c.req.json();
|
||||
if (!blockList || !sendBlockList || !verifiedAddressList) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
|
||||
if (!addressCreationSettingsUpdate) {
|
||||
return c.text(msgs.InvalidInputMsg, 400)
|
||||
}
|
||||
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
|
||||
return c.text(msgs.EnableSendMailMsg, 400)
|
||||
}
|
||||
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
|
||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||
return c.text(msgs.EnableKVMsg, 400)
|
||||
}
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
|
||||
JSON.stringify(blockList)
|
||||
@@ -320,9 +387,6 @@ api.post('/admin/account_settings', async (c) => {
|
||||
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
|
||||
JSON.stringify(verifiedAddressList)
|
||||
)
|
||||
if (fromBlockList?.length > 0 && !c.env.KV) {
|
||||
return c.text(msgs.EnableKVMsg, 400)
|
||||
}
|
||||
if (fromBlockList?.length > 0 && c.env.KV) {
|
||||
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
|
||||
}
|
||||
@@ -334,6 +398,20 @@ api.post('/admin/account_settings', async (c) => {
|
||||
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
|
||||
JSON.stringify(emailRuleSettings || {})
|
||||
)
|
||||
if (addressCreationSettingsUpdate.shouldUpdate) {
|
||||
if (addressCreationSettingsUpdate.shouldClear) {
|
||||
await c.env.DB.prepare(
|
||||
`DELETE FROM settings WHERE key = ?`
|
||||
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
|
||||
} else {
|
||||
await saveSetting(
|
||||
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
|
||||
JSON.stringify({
|
||||
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
return c.json({
|
||||
success: true
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Context } from "hono";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { WebhookSettings } from "../models";
|
||||
import { WebhookSettings, RawMailRow } from "../models";
|
||||
import { commonParseMail, sendWebhook } from "../common";
|
||||
import { resolveRawEmail } from "../gzip";
|
||||
|
||||
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.env.KV.get<WebhookSettings>(
|
||||
@@ -21,10 +22,12 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
|
||||
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
|
||||
const settings = await c.req.json<WebhookSettings>();
|
||||
// random raw email
|
||||
const { id: mailId, raw } = await c.env.DB.prepare(
|
||||
`SELECT id, raw FROM raw_mails ORDER BY RANDOM() LIMIT 1`
|
||||
).first<{ id: string, raw: string }>() || {};
|
||||
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw || "" };
|
||||
const mailRow = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails ORDER BY RANDOM() LIMIT 1`
|
||||
).first<RawMailRow>();
|
||||
const mailId = mailRow?.id;
|
||||
const raw = mailRow ? await resolveRawEmail(mailRow) : "";
|
||||
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
|
||||
const parsedEmail = await commonParseMail(parsedEmailContext);
|
||||
const res = await sendWebhook(settings, {
|
||||
id: mailId || "0",
|
||||
|
||||
@@ -24,6 +24,9 @@ export default {
|
||||
"SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue<SubdomainForwardAddressList[]>(c.env.SUBDOMAIN_FORWARD_ADDRESS_LIST),
|
||||
"DEFAULT_DOMAINS": utils.getDefaultDomains(c),
|
||||
"DOMAINS": utils.getDomains(c),
|
||||
"ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH": utils.getBooleanValue(c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH),
|
||||
"RANDOM_SUBDOMAIN_DOMAINS": utils.getRandomSubdomainDomains(c),
|
||||
"RANDOM_SUBDOMAIN_LENGTH": utils.getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, 8),
|
||||
"DOMAIN_LABELS": utils.getStringArray(c.env.DOMAIN_LABELS),
|
||||
|
||||
"HAS_JWT_SECRET": !!utils.getStringValue(c.env.JWT_SECRET),
|
||||
|
||||
@@ -26,6 +26,7 @@ api.get('/open_api/settings', async (c) => {
|
||||
"maxAddressLen": utils.getIntValue(c.env.MAX_ADDRESS_LEN, 30),
|
||||
"defaultDomains": utils.getDefaultDomains(c),
|
||||
"domains": utils.getDomains(c),
|
||||
"randomSubdomainDomains": utils.getRandomSubdomainDomains(c),
|
||||
"domainLabels": utils.getStringArray(c.env.DOMAIN_LABELS),
|
||||
"needAuth": needAuth,
|
||||
"adminContact": c.env.ADMIN_CONTACT,
|
||||
@@ -44,7 +45,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)
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
@@ -2,13 +2,29 @@ import { Context } from 'hono';
|
||||
import { Jwt } from 'hono/utils/jwt'
|
||||
import { WorkerMailerOptions } from 'worker-mailer';
|
||||
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue } from './utils';
|
||||
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
|
||||
import { unbindTelegramByAddress } from './telegram_api/common';
|
||||
import { CONSTANTS } from './constants';
|
||||
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||
import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
|
||||
import i18n from './i18n';
|
||||
|
||||
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
|
||||
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
|
||||
const MAX_RANDOM_SUBDOMAIN_ATTEMPTS = 5;
|
||||
const MAX_DOMAIN_LENGTH = 253;
|
||||
const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
const normalizeDomainValue = (domain: string): string => {
|
||||
return domain.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const isValidDomainLabel = (label: string): boolean => {
|
||||
return DOMAIN_LABEL_RE.test(label);
|
||||
}
|
||||
|
||||
const areValidDomainLabels = (labels: string[]): boolean => {
|
||||
return labels.length > 0 && labels.every((label) => isValidDomainLabel(label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if send mail is enabled for a specific domain
|
||||
@@ -66,6 +82,117 @@ export const generateRandomName = (c: Context<HonoCustomType>): string => {
|
||||
return fullName.substring(0, Math.min(fullName.length, maxLength));
|
||||
};
|
||||
|
||||
const generateRandomSubdomain = (c: Context<HonoCustomType>): string => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const length = Math.min(
|
||||
Math.max(getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, DEFAULT_RANDOM_SUBDOMAIN_LENGTH), 1),
|
||||
63
|
||||
);
|
||||
let subdomain = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
subdomain += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||
}
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
const allowRandomSubdomainForDomain = (
|
||||
c: Context<HonoCustomType>,
|
||||
domain: string
|
||||
): boolean => {
|
||||
const normalizedDomain = normalizeDomainValue(domain);
|
||||
return getRandomSubdomainDomains(c)
|
||||
.map((item) => normalizeDomainValue(item))
|
||||
.includes(normalizedDomain);
|
||||
}
|
||||
|
||||
const isCreateAddressSubdomainMatchEnvConfigured = (c: Context<HonoCustomType>): boolean => {
|
||||
return c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== undefined
|
||||
&& c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== null
|
||||
&& c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== "";
|
||||
}
|
||||
|
||||
export const getAddressCreationSettings = async (
|
||||
c: Context<HonoCustomType>
|
||||
): Promise<AddressCreationSettings> => {
|
||||
const value = await getJsonSetting<AddressCreationSettings>(
|
||||
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY
|
||||
);
|
||||
return new AddressCreationSettings(value);
|
||||
}
|
||||
|
||||
export const getAddressCreationSubdomainMatchStatus = async (
|
||||
c: Context<HonoCustomType>,
|
||||
existingSettings?: AddressCreationSettings
|
||||
): Promise<{
|
||||
envConfigured: boolean,
|
||||
envEnabled: boolean,
|
||||
storedEnabled: boolean | undefined,
|
||||
effectiveEnabled: boolean,
|
||||
}> => {
|
||||
const envConfigured = isCreateAddressSubdomainMatchEnvConfigured(c);
|
||||
const envEnabled = getBooleanValue(c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH);
|
||||
const addressCreationSettings = existingSettings || await getAddressCreationSettings(c);
|
||||
const storedEnabled = addressCreationSettings.enableSubdomainMatch;
|
||||
|
||||
// 业务约束:env=false 作为全局 kill switch,后台开关不能强行打开。
|
||||
const effectiveEnabled = envConfigured && !envEnabled
|
||||
? false
|
||||
: typeof storedEnabled === "boolean"
|
||||
? storedEnabled
|
||||
: envEnabled;
|
||||
|
||||
return {
|
||||
envConfigured,
|
||||
envEnabled,
|
||||
storedEnabled,
|
||||
effectiveEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const findMatchedAllowedDomain = (
|
||||
domain: string,
|
||||
allowDomains: string[],
|
||||
enableSubdomainMatch: boolean,
|
||||
): string | null => {
|
||||
const normalizedDomain = normalizeDomainValue(domain);
|
||||
if (normalizedDomain.length > MAX_DOMAIN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
const domainLabels = normalizedDomain.split('.');
|
||||
if (!areValidDomainLabels(domainLabels)) {
|
||||
return null;
|
||||
}
|
||||
const normalizedAllowDomains = allowDomains.map((allowDomain) => normalizeDomainValue(allowDomain));
|
||||
if (normalizedAllowDomains.includes(normalizedDomain)) {
|
||||
return normalizedDomain;
|
||||
}
|
||||
if (!enableSubdomainMatch) {
|
||||
return null;
|
||||
}
|
||||
const matchedDomain = [...normalizedAllowDomains]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((allowDomain) => {
|
||||
if (allowDomain.length > MAX_DOMAIN_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
const allowDomainLabels = allowDomain.split('.');
|
||||
if (!areValidDomainLabels(allowDomainLabels)) {
|
||||
return false;
|
||||
}
|
||||
if (domainLabels.length <= allowDomainLabels.length) {
|
||||
return false;
|
||||
}
|
||||
const prefixLabels = domainLabels.slice(0, domainLabels.length - allowDomainLabels.length);
|
||||
if (!areValidDomainLabels(prefixLabels)) {
|
||||
return false;
|
||||
}
|
||||
return allowDomainLabels.every((label, index) => {
|
||||
return domainLabels[domainLabels.length - allowDomainLabels.length + index] === label;
|
||||
});
|
||||
});
|
||||
return matchedDomain || null;
|
||||
}
|
||||
|
||||
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
|
||||
let error = null;
|
||||
try {
|
||||
@@ -148,12 +275,42 @@ const generatePasswordForAddress = async (
|
||||
return plainPassword;
|
||||
}
|
||||
|
||||
const insertAddressRecord = async (
|
||||
c: Context<HonoCustomType>,
|
||||
address: string,
|
||||
sourceMeta: string | undefined | null,
|
||||
msgs: ReturnType<typeof i18n.getMessagesbyContext>
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name, source_meta) VALUES(?, ?)`
|
||||
).bind(address, sourceMeta).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
// Fallback: source_meta field may not exist, try without it
|
||||
if (message && message.includes("source_meta")) {
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(address).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export const newAddress = async (
|
||||
c: Context<HonoCustomType>,
|
||||
{
|
||||
name,
|
||||
domain,
|
||||
enablePrefix,
|
||||
enableRandomSubdomain = false,
|
||||
checkLengthByConfig = true,
|
||||
addressPrefix = null,
|
||||
checkAllowDomains = true,
|
||||
@@ -162,13 +319,14 @@ export const newAddress = async (
|
||||
}: {
|
||||
name: string, domain: string | undefined | null,
|
||||
enablePrefix: boolean,
|
||||
enableRandomSubdomain?: boolean,
|
||||
checkLengthByConfig?: boolean,
|
||||
addressPrefix?: string | undefined | null,
|
||||
checkAllowDomains?: boolean,
|
||||
enableCheckNameRegex?: boolean,
|
||||
sourceMeta?: string | undefined | null,
|
||||
}
|
||||
): Promise<{ address: string, jwt: string, password?: string | null }> => {
|
||||
): Promise<{ address: string, jwt: string, password?: string | null, address_id: number }> => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
// trim whitespace and remove special characters
|
||||
name = name.trim().replace(getNameRegex(c), '')
|
||||
@@ -206,60 +364,71 @@ export const newAddress = async (
|
||||
if (!domain && allowDomains.length > 0) {
|
||||
const createAddressDefaultDomainFirst = getBooleanValue(c.env.CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST);
|
||||
if (createAddressDefaultDomainFirst) {
|
||||
domain = allowDomains[0];
|
||||
domain = normalizeDomainValue(allowDomains[0]);
|
||||
} else {
|
||||
domain = allowDomains[Math.floor(Math.random() * allowDomains.length)];
|
||||
domain = normalizeDomainValue(allowDomains[Math.floor(Math.random() * allowDomains.length)]);
|
||||
}
|
||||
} else if (typeof domain === "string") {
|
||||
domain = normalizeDomainValue(domain);
|
||||
}
|
||||
const { effectiveEnabled: enableSubdomainMatch } = await getAddressCreationSubdomainMatchStatus(c);
|
||||
const matchedAllowDomain = domain
|
||||
? findMatchedAllowedDomain(domain, allowDomains, enableSubdomainMatch)
|
||||
: null;
|
||||
// check domain is valid
|
||||
if (!domain || !allowDomains.includes(domain)) {
|
||||
if (!domain || !matchedAllowDomain) {
|
||||
throw new Error(msgs.InvalidDomainMsg)
|
||||
}
|
||||
// create address
|
||||
name = name + "@" + domain;
|
||||
try {
|
||||
// Try insert with source_meta field first
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name, source_meta) VALUES(?, ?)`
|
||||
).bind(name, sourceMeta).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
await updateAddressUpdatedAt(c, name);
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
// Fallback: source_meta field may not exist, try without it
|
||||
if (message && message.includes("source_meta")) {
|
||||
const result = await c.env.DB.prepare(
|
||||
`INSERT INTO address(name) VALUES(?)`
|
||||
).bind(name).run();
|
||||
if (!result.success) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
if (enableRandomSubdomain && !allowRandomSubdomainForDomain(c, domain)) {
|
||||
throw new Error(msgs.RandomSubdomainNotAllowedMsg)
|
||||
}
|
||||
|
||||
const maxAttempts = enableRandomSubdomain ? MAX_RANDOM_SUBDOMAIN_ATTEMPTS : 1;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const addressDomain = enableRandomSubdomain
|
||||
? `${generateRandomSubdomain(c)}.${domain}`
|
||||
: domain;
|
||||
const address = `${name}@${addressDomain}`;
|
||||
|
||||
try {
|
||||
await insertAddressRecord(c, address, sourceMeta, msgs);
|
||||
await updateAddressUpdatedAt(c, address);
|
||||
|
||||
const address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(address).first<number>("id");
|
||||
|
||||
if (!address_id) {
|
||||
throw new Error(msgs.FailedCreateAddressMsg);
|
||||
}
|
||||
|
||||
// 如果启用地址密码功能,自动生成密码
|
||||
const generatedPassword = await generatePasswordForAddress(c, address);
|
||||
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: address,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: address,
|
||||
password: generatedPassword,
|
||||
address_id: address_id,
|
||||
}
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
if (message && message.includes("UNIQUE")) {
|
||||
if (enableRandomSubdomain && attempt < maxAttempts - 1) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(msgs.AddressAlreadyExistsMsg)
|
||||
}
|
||||
await updateAddressUpdatedAt(c, name);
|
||||
} else if (message && message.includes("UNIQUE")) {
|
||||
throw new Error(msgs.AddressAlreadyExistsMsg)
|
||||
} else {
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
}
|
||||
const address_id = await c.env.DB.prepare(
|
||||
`SELECT id FROM address where name = ?`
|
||||
).bind(name).first<number>("id");
|
||||
|
||||
// 如果启用地址密码功能,自动生成密码
|
||||
const generatedPassword = await generatePasswordForAddress(c, name);
|
||||
|
||||
// create jwt
|
||||
const jwt = await Jwt.sign({
|
||||
address: name,
|
||||
address_id: address_id
|
||||
}, c.env.JWT_SECRET, "HS256")
|
||||
return {
|
||||
jwt: jwt,
|
||||
address: name,
|
||||
password: generatedPassword,
|
||||
}
|
||||
throw new Error(msgs.FailedCreateAddressMsg)
|
||||
}
|
||||
|
||||
const checkNameBlockList = async (
|
||||
@@ -425,7 +594,9 @@ export const handleListQuery = async (
|
||||
c: Context<HonoCustomType>,
|
||||
query: string, countQuery: string, params: string[],
|
||||
limit: string | number | undefined | null,
|
||||
offset: string | number | undefined | null
|
||||
offset: string | number | undefined | null,
|
||||
/** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */
|
||||
orderBy?: string
|
||||
): Promise<Response> => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (typeof limit === "string") {
|
||||
@@ -440,7 +611,8 @@ export const handleListQuery = async (
|
||||
if (offset == null || offset == undefined || offset < 0) {
|
||||
return c.text(msgs.InvalidOffsetMsg, 400)
|
||||
}
|
||||
const resultsQuery = `${query} order by id desc limit ? offset ?`;
|
||||
const orderClause = orderBy || 'id desc';
|
||||
const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`;
|
||||
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
||||
...params, limit, offset
|
||||
).all();
|
||||
@@ -450,13 +622,41 @@ export const handleListQuery = async (
|
||||
return c.json({ results, count });
|
||||
}
|
||||
|
||||
/**
|
||||
* handleListQuery variant for raw_mails: resolves raw_blob → raw after query.
|
||||
*/
|
||||
export const handleMailListQuery = async (
|
||||
c: Context<HonoCustomType>,
|
||||
query: string, countQuery: string, params: string[],
|
||||
limit: string | number | undefined | null,
|
||||
offset: string | number | undefined | null,
|
||||
orderBy?: string
|
||||
): Promise<Response> => {
|
||||
const { resolveRawEmailList } = await import('./gzip');
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
if (typeof limit === "string") limit = parseInt(limit);
|
||||
if (typeof offset === "string") offset = parseInt(offset);
|
||||
if (!limit || limit < 0 || limit > 100) return c.text(msgs.InvalidLimitMsg, 400);
|
||||
if (offset == null || offset == undefined || offset < 0) return c.text(msgs.InvalidOffsetMsg, 400);
|
||||
const orderClause = orderBy || 'id desc';
|
||||
const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`;
|
||||
const { results } = await c.env.DB.prepare(resultsQuery).bind(
|
||||
...params, limit, offset
|
||||
).all();
|
||||
const resolvedResults = await resolveRawEmailList(results);
|
||||
const count = offset == 0 ? await c.env.DB.prepare(
|
||||
countQuery
|
||||
).bind(...params).first("count") : 0;
|
||||
return c.json({ results: resolvedResults, count });
|
||||
}
|
||||
|
||||
export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): Promise<{
|
||||
sender: string,
|
||||
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 +667,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 +680,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 +700,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 +817,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,15 +1,16 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v' + '1.4.0',
|
||||
VERSION: 'v' + '1.5.0',
|
||||
|
||||
// DB Version
|
||||
DB_VERSION_KEY: 'db_version',
|
||||
DB_VERSION: "v0.0.6",
|
||||
DB_VERSION: "v0.0.7",
|
||||
|
||||
// DB settings
|
||||
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
|
||||
SEND_BLOCK_LIST_KEY: 'send_block_list',
|
||||
AUTO_CLEANUP_KEY: 'auto_cleanup',
|
||||
USER_SETTINGS_KEY: 'user_settings',
|
||||
ADDRESS_CREATION_SETTINGS_KEY: 'address_creation_settings',
|
||||
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
|
||||
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
|
||||
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',
|
||||
|
||||
@@ -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 { getJsonSetting } from "../utils";
|
||||
import { getBooleanValue, getJsonSetting } from "../utils";
|
||||
import { sendMailToTelegram } from "../telegram_api";
|
||||
import { auto_reply } from "./auto_reply";
|
||||
import { isBlocked } from "./black_list";
|
||||
@@ -11,6 +11,7 @@ import { extractEmailInfo } from "./ai_extract";
|
||||
import { forwardEmail } from "./forward";
|
||||
import { EmailRuleSettings } from "../models";
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { compressText } from "../gzip";
|
||||
|
||||
|
||||
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
|
||||
@@ -65,11 +66,49 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
|
||||
const message_id = message.headers.get("Message-ID");
|
||||
// save email
|
||||
try {
|
||||
const { success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, parsedEmailContext.rawEmail, message_id
|
||||
).run();
|
||||
let success = false;
|
||||
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
|
||||
let compressed: ArrayBuffer | null = null;
|
||||
try {
|
||||
compressed = await compressText(parsedEmailContext.rawEmail);
|
||||
} catch (gzipError) {
|
||||
console.error("gzip compression failed, falling back to plaintext", gzipError);
|
||||
}
|
||||
if (compressed) {
|
||||
try {
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, compressed, message_id
|
||||
).run());
|
||||
} catch (dbError) {
|
||||
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
|
||||
const errMsg = String(dbError);
|
||||
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
|
||||
console.error("raw_blob column missing, falling back to plaintext", dbError);
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, parsedEmailContext.rawEmail, message_id
|
||||
).run());
|
||||
} else {
|
||||
throw dbError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, parsedEmailContext.rawEmail, message_id
|
||||
).run());
|
||||
}
|
||||
} else {
|
||||
({ success } = await env.DB.prepare(
|
||||
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
|
||||
).bind(
|
||||
message.from, message.to, parsedEmailContext.rawEmail, message_id
|
||||
).run());
|
||||
}
|
||||
if (!success) {
|
||||
message.setReject(`Failed save message to ${message.to}`);
|
||||
console.error(`Failed save message from ${message.from} to ${message.to}`);
|
||||
|
||||
48
worker/src/gzip.ts
Normal file
48
worker/src/gzip.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Gzip compression/decompression utilities for D1 BLOB storage.
|
||||
* Uses Web Standard CompressionStream/DecompressionStream (native in CF Workers).
|
||||
*/
|
||||
|
||||
import { RawMailRow } from "./models";
|
||||
|
||||
export async function compressText(text: string): Promise<ArrayBuffer> {
|
||||
const stream = new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'));
|
||||
return new Response(stream).arrayBuffer();
|
||||
}
|
||||
|
||||
export async function decompressBlob(buffer: ArrayBuffer): Promise<string> {
|
||||
const stream = new Blob([buffer]).stream().pipeThrough(new DecompressionStream('gzip'));
|
||||
return new Response(stream).text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the raw email text from either raw_blob (gzip) or raw (plaintext) field.
|
||||
*/
|
||||
export async function resolveRawEmail(row: RawMailRow): Promise<string> {
|
||||
if (row.raw_blob) {
|
||||
try {
|
||||
// D1 returns BLOB as Array<number>, convert to ArrayBuffer for decompression
|
||||
return await decompressBlob(new Uint8Array(row.raw_blob as ArrayLike<number>).buffer);
|
||||
} catch (e) {
|
||||
console.error("decompressBlob failed, fallback to raw field", e);
|
||||
return row.raw ?? '';
|
||||
}
|
||||
}
|
||||
return row.raw ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single row: decompress raw_blob if present, strip raw_blob from result.
|
||||
*/
|
||||
export async function resolveRawEmailRow(row: RawMailRow): Promise<RawMailRow> {
|
||||
const raw = await resolveRawEmail(row);
|
||||
const { raw_blob: _, ...rest } = row;
|
||||
return { ...rest, raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch resolve raw emails for list queries using Promise.all.
|
||||
*/
|
||||
export async function resolveRawEmailList(rows: RawMailRow[]): Promise<RawMailRow[]> {
|
||||
return Promise.all(rows.map(row => resolveRawEmailRow(row)));
|
||||
}
|
||||
@@ -57,6 +57,7 @@ const messages: LocaleMessages = {
|
||||
NameTooShortMsg: "Name is too short",
|
||||
NameTooLongMsg: "Name is too long",
|
||||
InvalidDomainMsg: "Invalid domain",
|
||||
RandomSubdomainNotAllowedMsg: "Random subdomain is not enabled for this domain",
|
||||
AddressAlreadyExistsMsg: "Address already exists",
|
||||
MaxAddressCountReachedMsg: "Max address count reached",
|
||||
AddressNotBindedMsg: "Address is not binded",
|
||||
|
||||
@@ -55,6 +55,7 @@ export type LocaleMessages = {
|
||||
NameTooShortMsg: string
|
||||
NameTooLongMsg: string
|
||||
InvalidDomainMsg: string
|
||||
RandomSubdomainNotAllowedMsg: string
|
||||
AddressAlreadyExistsMsg: string
|
||||
MaxAddressCountReachedMsg: string
|
||||
AddressNotBindedMsg: string
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user