mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-12 19:49:52 +08:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca9bade48 | ||
|
|
f5ca8afcce | ||
|
|
8cf1150b15 | ||
|
|
8341cae28f | ||
|
|
635e0f4456 | ||
|
|
e81d46262d | ||
|
|
13426b2fbd | ||
|
|
2a52fd35d5 | ||
|
|
e38015a5b6 | ||
|
|
f98bbce234 | ||
|
|
2f8183e024 | ||
|
|
e115b99271 | ||
|
|
12ab6e1430 | ||
|
|
6e6e3f0877 | ||
|
|
286dfabd5f | ||
|
|
aaae21e92e | ||
|
|
3df55dce91 | ||
|
|
13b009f6ab | ||
|
|
0c337a1942 | ||
|
|
372f7b4149 | ||
|
|
abad88b986 | ||
|
|
928a35b7cb | ||
|
|
006ddf4aa4 | ||
|
|
f55e8c9818 | ||
|
|
4ef4c0d938 | ||
|
|
f4255f33a1 | ||
|
|
a2d37b8183 | ||
|
|
9b7a80ef54 | ||
|
|
a5e5fceab5 | ||
|
|
0df74ee5cc | ||
|
|
2b33d953fa | ||
|
|
1f969738f5 | ||
|
|
4b378ca710 | ||
|
|
bafd003cbd | ||
|
|
723e1fe75d | ||
|
|
566c6536d1 | ||
|
|
bde08b9d55 | ||
|
|
56351ed963 | ||
|
|
9583f0e1c5 | ||
|
|
898324777e | ||
|
|
0f418d7e94 | ||
|
|
e4b6c82e92 | ||
|
|
d367bc92b2 | ||
|
|
f0da9289fc | ||
|
|
decede7ed3 |
54
.claude/skills/version-upgrade/SKILL.md
Normal file
54
.claude/skills/version-upgrade/SKILL.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: version-upgrade
|
||||
description: 升级项目版本号。当用户要求升级版本、更新版本号、发布新版本时使用此 skill。支持 major(主版本)、minor(次版本)、patch(补丁版本)三种升级方式。
|
||||
---
|
||||
|
||||
# Version Upgrade
|
||||
|
||||
升级 cloudflare_temp_email 项目版本号。
|
||||
|
||||
## 需要修改的文件
|
||||
|
||||
1. `frontend/package.json` - version 字段
|
||||
2. `worker/package.json` - version 字段
|
||||
3. `worker/src/constants.ts` - VERSION 常量(格式:`VERSION: 'v' + '1.4.0'`)
|
||||
4. `pages/package.json` - version 字段
|
||||
5. `vitepress-docs/package.json` - version 字段
|
||||
6. `CHANGELOG.md` - 添加新版本占位符
|
||||
7. `CHANGELOG_EN.md` - 添加新版本占位符(英文)
|
||||
|
||||
## 版本升级流程
|
||||
|
||||
1. 读取 `frontend/package.json` 获取当前版本号
|
||||
2. 根据升级类型计算新版本号:
|
||||
- major: 1.3.0 → 2.0.0
|
||||
- minor: 1.3.0 → 1.4.0
|
||||
- patch: 1.3.0 → 1.3.1
|
||||
3. 更新所有 package.json 文件中的 version 字段
|
||||
4. 在 CHANGELOG.md 顶部添加新版本占位符
|
||||
5. 在 CHANGELOG_EN.md 顶部添加新版本占位符
|
||||
|
||||
## CHANGELOG 格式
|
||||
|
||||
中文 (CHANGELOG.md) - 在 `## v{OLD_VERSION}(main)` 之前插入:
|
||||
```markdown
|
||||
## v{VERSION}(main)
|
||||
|
||||
### Features
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
```
|
||||
|
||||
英文 (CHANGELOG_EN.md) - 同样格式。
|
||||
|
||||
## 提交信息格式
|
||||
|
||||
```
|
||||
feat: upgrade version to v{VERSION}
|
||||
|
||||
- Update version number to {VERSION} in all package.json files
|
||||
- Add v{VERSION} placeholder in CHANGELOG.md
|
||||
```
|
||||
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
**/node_modules/
|
||||
.git/
|
||||
vitepress-docs/
|
||||
smtp_proxy_server/
|
||||
mail-parser-wasm/
|
||||
db/
|
||||
**/.wrangler/
|
||||
**/dist/
|
||||
**/test-results/
|
||||
**/playwright-report/
|
||||
.DS_Store
|
||||
34
.github/workflows/e2e.yml
vendored
Normal file
34
.github/workflows/e2e.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
cd e2e
|
||||
docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
cd e2e
|
||||
docker compose down -v
|
||||
65
.github/workflows/frontend_deploy.yaml
vendored
65
.github/workflows/frontend_deploy.yaml
vendored
@@ -10,10 +10,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
deploy-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -25,40 +23,63 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
if: ${{ env.FRONTEND_NAME != '' }}
|
||||
run: |
|
||||
cd frontend/
|
||||
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
|
||||
export project_name=${{ secrets.FRONTEND_NAME }}
|
||||
pnpm install --no-frozen-lockfile
|
||||
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
|
||||
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying branch $frontend_branch"
|
||||
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
|
||||
pnpm run deploy:actions --project-name=$FRONTEND_NAME --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying branch production"
|
||||
pnpm run deploy --project-name=$project_name
|
||||
pnpm run deploy --project-name=$FRONTEND_NAME
|
||||
fi
|
||||
echo "Deploying production for ${{ github.ref_name }}"
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
|
||||
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
|
||||
if [ -n "$tg_mini_app_project_name" ]; then
|
||||
echo "Deploying telegram mini app $tg_mini_app_project_name"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying telegram mini app branch $frontend_branch"
|
||||
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying telegram mini app branch production"
|
||||
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
|
||||
fi
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
fi
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FRONTEND_NAME: ${{ secrets.FRONTEND_NAME }}
|
||||
|
||||
deploy-telegram-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Telegram Frontend for ${{ github.ref_name }}
|
||||
if: ${{ env.TG_FRONTEND_NAME != '' }}
|
||||
run: |
|
||||
cd frontend/
|
||||
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
|
||||
pnpm install --no-frozen-lockfile
|
||||
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
|
||||
if [ -n "$frontend_branch" ]; then
|
||||
echo "Deploying telegram mini app branch $frontend_branch"
|
||||
pnpm run deploy:actions:telegram --project-name=$TG_FRONTEND_NAME --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying telegram mini app branch production"
|
||||
pnpm run deploy:telegram --project-name=$TG_FRONTEND_NAME
|
||||
fi
|
||||
echo "Deployed telegram mini app for ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
TG_FRONTEND_NAME: ${{ secrets.TG_FRONTEND_NAME }}
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
name: Deploy Frontend with page function
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_config: ${{ steps.check.outputs.has_config }}
|
||||
steps:
|
||||
- name: Check PAGE_TOML
|
||||
id: check
|
||||
run: |
|
||||
if [ -n "$PAGE_TOML" ]; then
|
||||
echo "has_config=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_config=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
PAGE_TOML: ${{ secrets.PAGE_TOML }}
|
||||
|
||||
deploy:
|
||||
needs: check
|
||||
if: ${{ needs.check.outputs.has_config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -19,7 +41,6 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
@@ -33,7 +54,7 @@ jobs:
|
||||
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
|
||||
pnpm install --no-frozen-lockfile
|
||||
pnpm run deploy
|
||||
echo "Deploying prodcution for ${{ github.ref_name }}"
|
||||
echo "Deploying production for ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
82
.github/workflows/tag_build.yml
vendored
82
.github/workflows/tag_build.yml
vendored
@@ -6,10 +6,8 @@ on:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -21,7 +19,6 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
@@ -32,12 +29,58 @@ jobs:
|
||||
- name: Zip Frontend dist
|
||||
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend
|
||||
path: frontend/frontend.zip
|
||||
|
||||
build-telegram-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Build Telegram Frontend
|
||||
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:telegram:release
|
||||
|
||||
- name: Zip Telegram Frontend dist
|
||||
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: telegram-frontend
|
||||
path: frontend/telegram-frontend.zip
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: cp wrangler.toml
|
||||
run: cd worker && cp wrangler.toml.template wrangler.toml
|
||||
|
||||
@@ -57,11 +100,34 @@ jobs:
|
||||
pnpm build
|
||||
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
|
||||
|
||||
- name: Upload worker.js
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: worker-js
|
||||
path: worker/worker.js
|
||||
|
||||
- name: Upload wasm worker
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: worker-wasm
|
||||
path: worker/worker-with-wasm-mail-parser.zip
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-frontend, build-telegram-frontend, build-backend]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
frontend/frontend.zip
|
||||
frontend/telegram-frontend.zip
|
||||
worker/worker.js
|
||||
worker/worker-with-wasm-mail-parser.zip
|
||||
artifacts/frontend/frontend.zip
|
||||
artifacts/telegram-frontend/telegram-frontend.zip
|
||||
artifacts/worker-js/worker.js
|
||||
artifacts/worker-wasm/worker-with-wasm-mail-parser.zip
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -137,3 +137,8 @@ dist
|
||||
wrangler.toml
|
||||
.dev.vars
|
||||
pnpm-lock.yaml
|
||||
|
||||
# E2E test artifacts
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
e2e/.e2e-pids
|
||||
|
||||
35
AGENTS.md
35
AGENTS.md
@@ -1,35 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure
|
||||
- Backend: `worker/` (Workers API; entry `worker/src/worker.ts`, APIs under `worker/src/*_api/`).
|
||||
- Frontend: `frontend/` (Vue 3 app; routes in `frontend/src/router/`).
|
||||
- Pages middleware: `pages/functions/_middleware.js`.
|
||||
- Mail parser: `mail-parser-wasm/` (Rust WASM).
|
||||
- SMTP/IMAP proxy: `smtp_proxy_server/`.
|
||||
- DB schema/migrations: `db/`.
|
||||
- Docs: `vitepress-docs/`.
|
||||
|
||||
## Build & Dev Commands
|
||||
Run inside each folder:
|
||||
- Frontend: `pnpm dev`, `pnpm build`.
|
||||
- Worker: `pnpm dev`, `pnpm lint`, `pnpm build`.
|
||||
- Pages: `pnpm dev`.
|
||||
- Docs: `pnpm dev` in `vitepress-docs/`.
|
||||
- WASM: `wasm-pack build --release` in `mail-parser-wasm/`.
|
||||
- SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
|
||||
|
||||
## Coding Style
|
||||
- Follow existing module style. `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
|
||||
- Keep current naming patterns: `*_api/`, `utils/`, `models/`.
|
||||
- ESM imports only (`type: module`).
|
||||
|
||||
## Testing
|
||||
- No formal test runner. Validate with local dev servers and key flows (login, inbox, send/receive).
|
||||
|
||||
## Commits & PRs
|
||||
- Use Conventional Commits (`feat:`, `fix:`, `docs:`). Recent history includes PR numbers like `(#123)`.
|
||||
- PRs should explain scope and add screenshots for UI changes.
|
||||
|
||||
## Config Tips
|
||||
- Worker settings in `worker/wrangler.toml` (see template for bindings).
|
||||
- Frontend uses `VITE_*` env vars. Don’t commit secrets.
|
||||
55
CHANGELOG.md
55
CHANGELOG.md
@@ -6,7 +6,60 @@
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
</p>
|
||||
|
||||
## v1.2.1(main)
|
||||
## v1.4.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |用户注册| 新增用户注册邮箱正则校验功能,管理员可配置邮箱格式验证规则
|
||||
- feat: |前端| 新增可配置的 Status 菜单按钮,通过 `STATUS_URL` 环境变量配置状态监控页面链接
|
||||
- feat: |SMTP| SMTP 代理服务支持 STARTTLS,通过 `smtp_tls_cert` 和 `smtp_tls_key` 环境变量配置
|
||||
- feat: |Webhook| Webhook 设置页面新增预设模板下拉菜单,支持 Message Pusher、Bark、ntfy 一键填充配置
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Telegram| 修复 admin 用户通过 Telegram MiniApp 查看邮件时报 `Auth date expired` 的问题,支持 admin 密码认证查看邮件
|
||||
- fix: |Admin API| 修复 `/admin/account_settings` 在未配置 KV 且 `fromBlockList` 为空时触发 `Cannot read properties of undefined (reading 'put')` 的问题
|
||||
- fix: |数据库| 修复 `DB_INIT_QUERIES` 缺少 `idx_raw_mails_message_id` 索引导致 `UPDATE raw_mails ... WHERE message_id = ?` 全表扫描的问题,同步 `schema.sql` 与初始化代码,新增 v0.0.6 迁移逻辑
|
||||
- fix: |文档| 修复 User Mail API 文档中错误使用 `x-admin-auth` 的问题,改为正确的 `x-user-token`
|
||||
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
|
||||
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
|
||||
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
|
||||
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义
|
||||
- fix: |API| 修复 `requset_send_mail_access` API 路径拼写错误,改为 `request_send_mail_access`
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
|
||||
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
|
||||
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标
|
||||
- feat: |后台管理| 邮箱地址列表来源IP添加 ip.im 查询链接,点击可快速查看IP信息
|
||||
- docs: |文档| 修复 VitePress 中英文切换路径错误,改用双前缀 locale 配置
|
||||
- feat: |IMAP 代理| 重构 IMAP 服务端,拆分为独立模块(HTTP 客户端、邮箱、消息),使用 `deferToThread` 异步 HTTP 避免阻塞 Twisted reactor,使用后端 `id` 作为稳定 UID,新增 STARTTLS 支持、LRU 消息缓存、session 级 flags 管理、SEARCH 命令支持、JWT 凭证和地址+密码双登录方式,新增完整测试套件
|
||||
- fix: |IMAP 代理| 修复 `getHeaders()` 过滤逻辑、`store()` 崩溃问题
|
||||
- fix: |邮件解析| 修复 `parse_email.py` 中使用私有属性 `_payload` 导致编码错误的问题,改用 `get_payload(decode=True)` 正确解码邮件体
|
||||
|
||||
## v1.3.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |OAuth2| 新增 OAuth2 邮箱格式转换功能,支持通过正则表达式转换第三方登录返回的邮箱格式(如将 `user@domain` 转换为 `user@custom.domain`)
|
||||
- feat: |OAuth2| 新增 OAuth2 提供商 SVG 图标支持,管理员可为登录按钮配置自定义图标,预置 GitHub、Linux Do、Authentik 模板图标
|
||||
- feat: |发送邮件| 未配置发送邮件功能时自动隐藏发送邮件 tab、发件箱 tab 和回复按钮
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |用户地址| 修复禁止匿名创建时,已登录用户地址数量限制检查失效的问题,新增公共函数 `isAddressCountLimitReached` 统一处理地址数量限制逻辑
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |代码重构| 提取地址数量限制检查为公共函数,优化代码复用性
|
||||
- perf: |性能优化| GET 请求中的地址活动时间更新改为异步执行,使用 `waitUntil` 不阻塞响应
|
||||
|
||||
## v1.2.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
||||
@@ -6,7 +6,60 @@
|
||||
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
|
||||
</p>
|
||||
|
||||
## v1.2.1(main)
|
||||
## v1.4.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |User Registration| Add email regex validation for user registration, admins can configure email format validation rules
|
||||
- feat: |Frontend| Add configurable Status menu button via `STATUS_URL` environment variable for status monitoring page link
|
||||
- feat: |SMTP| Add STARTTLS support for SMTP proxy server via `smtp_tls_cert` and `smtp_tls_key` environment variables
|
||||
- feat: |Webhook| Add preset templates dropdown to Webhook settings page, supporting one-click fill for Message Pusher, Bark, and ntfy
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Telegram| Fix admin users unable to view emails via Telegram MiniApp due to `Auth date expired` error, support admin password auth for viewing emails
|
||||
- fix: |Admin API| Fix `/admin/account_settings` throwing `Cannot read properties of undefined (reading 'put')` when KV is not configured and `fromBlockList` is empty
|
||||
- fix: |Database| Fix missing `idx_raw_mails_message_id` index in `DB_INIT_QUERIES` causing full table scan on `UPDATE raw_mails ... WHERE message_id = ?`, sync `schema.sql` with init code, add v0.0.6 migration
|
||||
- fix: |Docs| Fix User Mail API documentation incorrectly using `x-admin-auth`, changed to correct `x-user-token`
|
||||
- fix: |Frontend| Fix email content text being unreadable in dark theme, improve dark mode styles for plain text mail and Shadow DOM rendering
|
||||
- docs: |Docs| Add Admin API documentation for delete mail, delete address, clear inbox, and clear sent items
|
||||
- fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text
|
||||
- fix: |Security| Fix XSS vulnerability in reply/forward mail content, sanitize HTML with DOMPurify whitelist and escape plain text
|
||||
- fix: |API| Fix typo in `requset_send_mail_access` API path, renamed to `request_send_mail_access`
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
|
||||
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
|
||||
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons
|
||||
- feat: |Admin| Add ip.im lookup link for source IP in address list, click to quickly view IP information
|
||||
- docs: |Docs| Fix VitePress i18n language switch path error, use dual-prefix locale configuration
|
||||
- feat: |IMAP Proxy| Refactor IMAP server into separate modules (HTTP client, mailbox, message), use `deferToThread` for async HTTP to avoid blocking Twisted reactor, use backend `id` as stable UID, add STARTTLS support, LRU message cache, session-local flags management, SEARCH command support, JWT credential and address+password dual login methods, and comprehensive test suite
|
||||
- fix: |IMAP Proxy| Fix `getHeaders()` filtering and `store()` crash
|
||||
- fix: |Email Parser| Fix `parse_email.py` using private `_payload` attribute causing encoding errors, use `get_payload(decode=True)` for proper email body decoding
|
||||
|
||||
## v1.3.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |OAuth2| Add email format transformation support for OAuth2, allowing regex-based email format conversion from third-party login providers (e.g., transform `user@domain` to `user@custom.domain`)
|
||||
- feat: |OAuth2| Add SVG icon support for OAuth2 providers, admins can configure custom icons for login buttons, preset icons for GitHub, Linux Do, Authentik templates
|
||||
- feat: |Send Mail| Auto-hide sendmail tab, sendbox tab, and reply button when send mail is not configured
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |User Address| Fix address count limit check failure when anonymous creation is disabled for logged-in users, add public function `isAddressCountLimitReached` to unify address count limit logic
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: |Code Refactoring| Extract address count limit check as a public function to improve code reusability
|
||||
- perf: |Performance| Change address activity time update in GET requests to async execution using `waitUntil`, non-blocking response
|
||||
|
||||
## v1.2.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
||||
67
CLAUDE.md
Normal file
67
CLAUDE.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **Backend**: `worker/` — Cloudflare Workers API using Hono framework. Entry: `worker/src/worker.ts`, APIs under `worker/src/*_api/`.
|
||||
- **Frontend**: `frontend/` — Vue 3 + Naive UI app deployed to Cloudflare Pages. Routes in `frontend/src/router/`.
|
||||
- **Pages middleware**: `pages/functions/_middleware.js` — Routes API calls to Worker backend.
|
||||
- **Mail parser**: `mail-parser-wasm/` — Rust WASM email parser.
|
||||
- **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).
|
||||
- **Changelogs**: `CHANGELOG.md` (中文) + `CHANGELOG_EN.md` (English).
|
||||
|
||||
## Build & Dev Commands
|
||||
|
||||
Run inside each subfolder with `pnpm`:
|
||||
|
||||
| Folder | Dev | Build | Lint | Deploy |
|
||||
|--------|-----|-------|------|--------|
|
||||
| `worker/` | `pnpm dev` | `pnpm build` | `pnpm lint` | `pnpm deploy` |
|
||||
| `frontend/` | `pnpm dev` | `pnpm build` | — | `pnpm deploy` |
|
||||
| `vitepress-docs/` | `pnpm dev` | `pnpm build` | — | — |
|
||||
| `mail-parser-wasm/` | — | `wasm-pack build --release` | — | — |
|
||||
|
||||
SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
|
||||
|
||||
## 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:`.
|
||||
- PRs should explain scope; add screenshots for UI changes.
|
||||
- Use squash merge for PRs.
|
||||
|
||||
## Post-Task Checklist (IMPORTANT)
|
||||
|
||||
After completing any feature, bug fix, or improvement, **always check**:
|
||||
|
||||
1. **CHANGELOG.md** (中文) and **CHANGELOG_EN.md** (English) — both must be updated under the current `(main)` version section with the change entry. Follow the existing format: `- feat/fix/docs: |模块| 描述`.
|
||||
2. **Documentation** — if the change involves new environment variables, new API endpoints, or configuration changes, update the corresponding docs in `vitepress-docs/docs/zh/` and `vitepress-docs/docs/en/`. Key files:
|
||||
- `guide/worker-vars.md` — Worker environment variables
|
||||
- `guide/ui/` — Frontend deployment docs
|
||||
- `guide/feature/` — Feature-specific docs
|
||||
- `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).
|
||||
- Frontend uses `VITE_*` env vars. Don't commit secrets.
|
||||
82
README.md
82
README.md
@@ -29,37 +29,37 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">🇨🇳 中文文档</a> |
|
||||
<a href="README_EN.md">🇺🇸 English Document</a>
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a>
|
||||
</p>
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
|
||||
**🎉 一个功能完整的临时邮箱服务!**
|
||||
**一个功能完整的临时邮箱服务!**
|
||||
|
||||
- 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||
- ⚡ **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||
- 🔐 **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
|
||||
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||
- **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||
- **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
|
||||
|
||||
## 📚 部署文档 - 快速开始
|
||||
## 部署文档 - 快速开始
|
||||
|
||||
[📖 部署文档](https://temp-mail-docs.awsl.uk) | [🚀 Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
|
||||
[部署文档](https://temp-mail-docs.awsl.uk) | [Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
|
||||
|
||||
<a href="https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## 📝 更新日志
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG](CHANGELOG.md) 了解最新更新内容。
|
||||
|
||||
## 🎯 在线体验
|
||||
## 在线体验
|
||||
|
||||
立即体验 → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>📊 服务状态监控(点击收缩/展开)</summary>
|
||||
<summary>服务状态监控(点击收缩/展开)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -69,7 +69,7 @@
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⭐ Star History(点击收缩/展开)</summary>
|
||||
<summary>Star History(点击收缩/展开)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
@@ -80,32 +80,32 @@
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>📖 目录(点击收缩/展开)</summary>
|
||||
<summary>目录(点击收缩/展开)</summary>
|
||||
|
||||
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
|
||||
- [📚 部署文档 - 快速开始](#-部署文档---快速开始)
|
||||
- [📝 更新日志](#-更新日志)
|
||||
- [🎯 在线体验](#-在线体验)
|
||||
- [✨ 核心功能](#-核心功能)
|
||||
- [📧 邮件处理](#-邮件处理)
|
||||
- [👥 用户管理](#-用户管理)
|
||||
- [🔧 管理功能](#-管理功能)
|
||||
- [🌐 多语言与界面](#-多语言与界面)
|
||||
- [🤖 集成与扩展](#-集成与扩展)
|
||||
- [🏗️ 技术架构](#️-技术架构)
|
||||
- [🏛️ 系统架构](#️-系统架构)
|
||||
- [🛠️ 技术栈](#️-技术栈)
|
||||
- [📦 主要组件](#-主要组件)
|
||||
- [🌟 加入社区](#-加入社区)
|
||||
- [部署文档 - 快速开始](#部署文档---快速开始)
|
||||
- [更新日志](#更新日志)
|
||||
- [在线体验](#在线体验)
|
||||
- [核心功能](#核心功能)
|
||||
- [邮件处理](#邮件处理)
|
||||
- [用户管理](#用户管理)
|
||||
- [管理功能](#管理功能)
|
||||
- [多语言与界面](#多语言与界面)
|
||||
- [集成与扩展](#集成与扩展)
|
||||
- [技术架构](#技术架构)
|
||||
- [系统架构](#系统架构)
|
||||
- [技术栈](#技术栈)
|
||||
- [主要组件](#主要组件)
|
||||
- [加入社区](#加入社区)
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ 核心功能
|
||||
## 核心功能
|
||||
|
||||
<details open>
|
||||
<summary>✨ 核心功能详情(点击收缩/展开)</summary>
|
||||
<summary>核心功能详情(点击收缩/展开)</summary>
|
||||
|
||||
### 📧 邮件处理
|
||||
### 邮件处理
|
||||
|
||||
- [x] 使用 `rust wasm` 解析邮件,解析速度快,几乎所有邮件都能解析,node 的解析模块解析邮件失败的邮件,rust wasm 也能解析成功
|
||||
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
@@ -116,7 +116,7 @@
|
||||
- [x] 垃圾邮件检测和黑白名单配置
|
||||
- [x] 邮件转发功能,支持全局转发地址
|
||||
|
||||
### 👥 用户管理
|
||||
### 用户管理
|
||||
|
||||
- [x] 使用 `凭证` 重新登录之前的邮箱
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
@@ -125,7 +125,7 @@
|
||||
- [x] 用户角色管理,支持多角色域名和前缀配置
|
||||
- [x] 用户收件箱查看,支持地址和关键词过滤
|
||||
|
||||
### 🔧 管理功能
|
||||
### 管理功能
|
||||
|
||||
- [x] 完整的 admin 控制台
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
@@ -134,7 +134,7 @@
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
|
||||
### 🌐 多语言与界面
|
||||
### 多语言与界面
|
||||
|
||||
- [x] 前后台均支持多语言
|
||||
- [x] 现代化 UI 设计,支持响应式布局
|
||||
@@ -142,7 +142,7 @@
|
||||
- [x] 使用 shadow DOM 防止样式污染
|
||||
- [x] 支持 URL JWT 参数自动登录
|
||||
|
||||
### 🤖 集成与扩展
|
||||
### 集成与扩展
|
||||
|
||||
- [x] 完整的 `Telegram Bot` 支持,以及 `Telegram` 推送,Telegram Bot 小程序
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件,`IMAP` 查看邮件
|
||||
@@ -152,19 +152,19 @@
|
||||
|
||||
</details>
|
||||
|
||||
## 🏗️ 技术架构
|
||||
## 技术架构
|
||||
|
||||
<details>
|
||||
<summary>🏗️ 技术架构详情(点击收缩/展开)</summary>
|
||||
<summary>技术架构详情(点击收缩/展开)</summary>
|
||||
|
||||
### 🏛️ 系统架构
|
||||
### 系统架构
|
||||
|
||||
- **数据库**: Cloudflare D1 作为主数据库
|
||||
- **前端部署**: 使用 Cloudflare Pages 部署前端
|
||||
- **后端部署**: 使用 Cloudflare Workers 部署后端
|
||||
- **邮件转发**: 使用 Cloudflare Email Routing
|
||||
|
||||
### 🛠️ 技术栈
|
||||
### 技术栈
|
||||
|
||||
- **前端**: Vue 3 + Vite + TypeScript
|
||||
- **后端**: TypeScript + Cloudflare Workers
|
||||
@@ -173,7 +173,7 @@
|
||||
- **存储**: Cloudflare KV + R2 (可选 S3)
|
||||
- **代理服务**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 📦 主要组件
|
||||
### 主要组件
|
||||
|
||||
- **Worker**: 核心后端服务
|
||||
- **Frontend**: Vue 3 用户界面
|
||||
@@ -192,6 +192,6 @@ nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
进行验证。
|
||||
|
||||
## 🌟 加入社区
|
||||
## 加入社区
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
82
README_EN.md
82
README_EN.md
@@ -29,37 +29,37 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">🇨🇳 中文文档</a> |
|
||||
<a href="README_EN.md">🇺🇸 English Document</a>
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a>
|
||||
</p>
|
||||
|
||||
> This project is for learning and personal use only. Please do not use it for any illegal activities, or you will be responsible for the consequences.
|
||||
|
||||
**🎉 A fully-featured temporary email service!**
|
||||
**A fully-featured temporary email service!**
|
||||
|
||||
- 🆓 **Completely Free** - Built on Cloudflare's free services with zero cost
|
||||
- ⚡ **High Performance** - Rust WASM email parsing for extremely fast response
|
||||
- 🎨 **Modern UI** - Responsive design with multi-language support and easy operation
|
||||
- 🔐 **Address Password** - Support setting individual passwords for email addresses to enhance security (enabled via `ENABLE_ADDRESS_PASSWORD`)
|
||||
- **Completely Free** - Built on Cloudflare's free services with zero cost
|
||||
- **High Performance** - Rust WASM email parsing for extremely fast response
|
||||
- **Modern UI** - Responsive design with multi-language support and easy operation
|
||||
- **Address Password** - Support setting individual passwords for email addresses to enhance security (enabled via `ENABLE_ADDRESS_PASSWORD`)
|
||||
|
||||
## 📚 Deployment Documentation - Quick Start
|
||||
## Deployment Documentation - Quick Start
|
||||
|
||||
[📖 Documentation](https://temp-mail-docs.awsl.uk) | [🚀 Github Action Deployment Guide](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
|
||||
[Documentation](https://temp-mail-docs.awsl.uk) | [Github Action Deployment Guide](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
|
||||
|
||||
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## 📝 Changelog
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG](CHANGELOG.md) for the latest updates.
|
||||
|
||||
## 🎯 Live Demo
|
||||
## Live Demo
|
||||
|
||||
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>📊 Service Status Monitoring (Click to expand/collapse)</summary>
|
||||
<summary>Service Status Monitoring (Click to expand/collapse)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -69,7 +69,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>⭐ Star History (Click to expand/collapse)</summary>
|
||||
<summary>Star History (Click to expand/collapse)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
@@ -80,32 +80,32 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>📖 Table of Contents (Click to expand/collapse)</summary>
|
||||
<summary>Table of Contents (Click to expand/collapse)</summary>
|
||||
|
||||
- [Cloudflare Temp Email - Free Temporary Email Service](#cloudflare-temp-email---free-temporary-email-service)
|
||||
- [📚 Deployment Documentation - Quick Start](#-deployment-documentation---quick-start)
|
||||
- [📝 Changelog](#-changelog)
|
||||
- [🎯 Live Demo](#-live-demo)
|
||||
- [✨ Core Features](#-core-features)
|
||||
- [📧 Email Processing](#-email-processing)
|
||||
- [👥 User Management](#-user-management)
|
||||
- [🔧 Admin Features](#-admin-features)
|
||||
- [🌐 Multi-language \& Interface](#-multi-language--interface)
|
||||
- [🤖 Integration \& Extensions](#-integration--extensions)
|
||||
- [🏗️ Technical Architecture](#️-technical-architecture)
|
||||
- [🏛️ System Architecture](#️-system-architecture)
|
||||
- [🛠️ Tech Stack](#️-tech-stack)
|
||||
- [📦 Main Components](#-main-components)
|
||||
- [🌟 Join the Community](#-join-the-community)
|
||||
- [Deployment Documentation - Quick Start](#deployment-documentation---quick-start)
|
||||
- [Changelog](#changelog)
|
||||
- [Live Demo](#live-demo)
|
||||
- [Core Features](#core-features)
|
||||
- [Email Processing](#email-processing)
|
||||
- [User Management](#user-management)
|
||||
- [Admin Features](#admin-features)
|
||||
- [Multi-language \& Interface](#multi-language--interface)
|
||||
- [Integration \& Extensions](#integration--extensions)
|
||||
- [Technical Architecture](#technical-architecture)
|
||||
- [System Architecture](#system-architecture)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Main Components](#main-components)
|
||||
- [Join the Community](#join-the-community)
|
||||
|
||||
</details>
|
||||
|
||||
## ✨ Core Features
|
||||
## Core Features
|
||||
|
||||
<details open>
|
||||
<summary>✨ Core Features Details (Click to expand/collapse)</summary>
|
||||
<summary>Core Features Details (Click to expand/collapse)</summary>
|
||||
|
||||
### 📧 Email Processing
|
||||
### Email Processing
|
||||
|
||||
- [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
|
||||
@@ -116,7 +116,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] Spam detection and blacklist/whitelist configuration
|
||||
- [x] Email forwarding feature with global forwarding address support
|
||||
|
||||
### 👥 User Management
|
||||
### User Management
|
||||
|
||||
- [x] Use `credentials` to log in to previously used mailboxes
|
||||
- [x] Add complete user registration and login functionality. Users can bind email addresses and automatically obtain email JWT credentials to switch between different mailboxes after binding
|
||||
@@ -125,7 +125,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] User role management with support for multi-role domain and prefix configuration
|
||||
- [x] User inbox viewing with address and keyword filtering support
|
||||
|
||||
### 🔧 Admin Features
|
||||
### Admin Features
|
||||
|
||||
- [x] Complete admin console
|
||||
- [x] Create mailboxes without prefix in `admin` backend
|
||||
@@ -134,7 +134,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] Get mailboxes with custom names, `admin` can configure blacklist
|
||||
- [x] Add access password for use as a private site
|
||||
|
||||
### 🌐 Multi-language & Interface
|
||||
### Multi-language & Interface
|
||||
|
||||
- [x] Both frontend and backend support multi-language
|
||||
- [x] Modern UI design with responsive layout
|
||||
@@ -142,7 +142,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- [x] Use shadow DOM to prevent style pollution
|
||||
- [x] Support URL JWT parameter auto-login
|
||||
|
||||
### 🤖 Integration & Extensions
|
||||
### Integration & Extensions
|
||||
|
||||
- [x] Complete `Telegram Bot` support, `Telegram` push notifications, and Telegram Bot mini app
|
||||
- [x] Add `SMTP proxy server` supporting `SMTP` for sending emails and `IMAP` for viewing emails
|
||||
@@ -152,19 +152,19 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
</details>
|
||||
|
||||
## 🏗️ Technical Architecture
|
||||
## Technical Architecture
|
||||
|
||||
<details>
|
||||
<summary>🏗️ Technical Architecture Details (Click to expand/collapse)</summary>
|
||||
<summary>Technical Architecture Details (Click to expand/collapse)</summary>
|
||||
|
||||
### 🏛️ System Architecture
|
||||
### System Architecture
|
||||
|
||||
- **Database**: Cloudflare D1 as the main database
|
||||
- **Frontend Deployment**: Deploy frontend using Cloudflare Pages
|
||||
- **Backend Deployment**: Deploy backend using Cloudflare Workers
|
||||
- **Email Routing**: Use Cloudflare Email Routing
|
||||
|
||||
### 🛠️ Tech Stack
|
||||
### Tech Stack
|
||||
|
||||
- **Frontend**: Vue 3 + Vite + TypeScript
|
||||
- **Backend**: TypeScript + Cloudflare Workers
|
||||
@@ -173,7 +173,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
- **Storage**: Cloudflare KV + R2 (optional S3)
|
||||
- **Proxy Service**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 📦 Main Components
|
||||
### Main Components
|
||||
|
||||
- **Worker**: Core backend service
|
||||
- **Frontend**: Vue 3 user interface
|
||||
@@ -191,6 +191,6 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
|
||||
## 🌟 Join the Community
|
||||
## Join the Community
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
13
e2e/Dockerfile.e2e
Normal file
13
e2e/Dockerfile.e2e
Normal file
@@ -0,0 +1,13 @@
|
||||
# Keep this version in sync with @playwright/test in package.json
|
||||
FROM mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
|
||||
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app/e2e
|
||||
|
||||
COPY e2e/package.json e2e/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY e2e/ .
|
||||
|
||||
ENTRYPOINT ["/app/e2e/scripts/docker-entrypoint.sh"]
|
||||
23
e2e/Dockerfile.frontend
Normal file
23
e2e/Dockerfile.frontend
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM node:20-slim
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
||||
|
||||
COPY 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.
|
||||
RUN mv vite.config.js vite.config.original.js && \
|
||||
echo 'import config from "./vite.config.original.js";\
|
||||
config.server = { ...config.server, allowedHosts: true };\
|
||||
export default config;' > vite.config.js
|
||||
|
||||
ENV VITE_API_BASE=http://worker:8787
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["pnpm", "exec", "vite", "--port", "5173", "--host", "0.0.0.0"]
|
||||
18
e2e/Dockerfile.worker
Normal file
18
e2e/Dockerfile.worker
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /app/worker
|
||||
|
||||
COPY worker/package.json worker/pnpm-lock.yaml ./
|
||||
COPY worker/patches/ patches/
|
||||
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
||||
|
||||
COPY worker/src/ src/
|
||||
COPY worker/tsconfig.json ./
|
||||
COPY e2e/fixtures/wrangler.toml.e2e wrangler.toml
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
|
||||
57
e2e/README.md
Normal file
57
e2e/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# E2E Tests
|
||||
|
||||
End-to-end tests for Cloudflare Temp Email using [Playwright](https://playwright.dev/) and [Mailpit](https://mailpit.axllent.org/), fully containerized with Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** and **Docker Compose**
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
|
||||
# Build, start all services, run tests, and exit
|
||||
npm test
|
||||
|
||||
# Clean up containers and volumes
|
||||
npm run test:down
|
||||
```
|
||||
|
||||
`npm test` runs `docker compose up --build`, which:
|
||||
1. Starts **Mailpit** (SMTP on :1025, HTTP API on :8025)
|
||||
2. Builds and starts the **Worker** (wrangler dev on :8787)
|
||||
3. Builds and starts the **Frontend** (vite dev on :5173)
|
||||
4. Builds and runs the **E2E runner** (Playwright), which waits for services, initializes the DB, and runs all tests
|
||||
|
||||
The exit code reflects the test result.
|
||||
|
||||
## Test Structure
|
||||
|
||||
| Project | Directory | What it tests |
|
||||
|---------|-----------|---------------|
|
||||
| `api` | `tests/api/` | Worker API endpoints — health check, address CRUD, send mail via SMTP |
|
||||
| `browser` | `tests/browser/` | Frontend UI — login, inbox view, reply with HTML, XSS sanitization |
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Container | Port | Purpose |
|
||||
|---------|-----------|------|---------|
|
||||
| Mailpit SMTP | `mailpit` | 1025 | Captures outgoing emails |
|
||||
| Mailpit HTTP | `mailpit` | 8025 | API to verify captured emails |
|
||||
| Worker | `worker` | 8787 | Backend API with E2E config |
|
||||
| Frontend | `frontend` | 5173 | Vue frontend dev server |
|
||||
|
||||
## Test Results
|
||||
|
||||
Test results and HTML reports are exported via volumes:
|
||||
- `e2e/test-results/` — test artifacts
|
||||
- `e2e/playwright-report/` — HTML report
|
||||
|
||||
## Configuration
|
||||
|
||||
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
|
||||
- `E2E_TEST_MODE = true` — enables test seed endpoint
|
||||
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
|
||||
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
|
||||
- SMTP pointed at Mailpit container (`mailpit:1025`)
|
||||
96
e2e/docker-compose.yml
Normal file
96
e2e/docker-compose.yml
Normal file
@@ -0,0 +1,96 @@
|
||||
services:
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.29
|
||||
ports:
|
||||
- "1025:1025"
|
||||
- "8025:8025"
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8787:8787"
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8787/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
|
||||
smtp-proxy:
|
||||
build:
|
||||
context: ../smtp_proxy_server
|
||||
dockerfile: dockerfile
|
||||
ports:
|
||||
- "11025:8025"
|
||||
- "11143:11143"
|
||||
environment:
|
||||
PROXY_URL: http://worker:8787
|
||||
PORT: "8025"
|
||||
IMAP_PORT: "11143"
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
|
||||
smtp-proxy-tls:
|
||||
build:
|
||||
context: ../smtp_proxy_server
|
||||
dockerfile: dockerfile
|
||||
ports:
|
||||
- "11026:8026"
|
||||
- "11144:11144"
|
||||
environment:
|
||||
PROXY_URL: http://worker:8787
|
||||
PORT: "8026"
|
||||
IMAP_PORT: "11144"
|
||||
smtp_tls_cert: /certs/cert.pem
|
||||
smtp_tls_key: /certs/key.pem
|
||||
imap_tls_cert: /certs/cert.pem
|
||||
imap_tls_key: /certs/key.pem
|
||||
entrypoint: ["/bin/bash", "/e2e-scripts/smtp-tls-entrypoint.sh"]
|
||||
volumes:
|
||||
- ./scripts:/e2e-scripts:ro
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
|
||||
e2e-runner:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.e2e
|
||||
environment:
|
||||
WORKER_URL: http://worker:8787
|
||||
FRONTEND_URL: http://frontend:5173
|
||||
MAILPIT_API: http://mailpit:8025/api
|
||||
SMTP_PROXY_HOST: smtp-proxy
|
||||
SMTP_PROXY_SMTP_PORT: "8025"
|
||||
SMTP_PROXY_IMAP_PORT: "11143"
|
||||
SMTP_PROXY_TLS_HOST: smtp-proxy-tls
|
||||
SMTP_PROXY_TLS_SMTP_PORT: "8026"
|
||||
SMTP_PROXY_TLS_IMAP_PORT: "11144"
|
||||
CI: "true"
|
||||
depends_on:
|
||||
worker:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_started
|
||||
smtp-proxy:
|
||||
condition: service_started
|
||||
smtp-proxy-tls:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./test-results:/app/e2e/test-results
|
||||
- ./playwright-report:/app/e2e/playwright-report
|
||||
210
e2e/fixtures/test-helpers.ts
Normal file
210
e2e/fixtures/test-helpers.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { APIRequestContext } from '@playwright/test';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export const WORKER_URL = process.env.WORKER_URL!;
|
||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||
export const TEST_DOMAIN = 'test.example.com';
|
||||
|
||||
/**
|
||||
* Create a new email address via the worker API.
|
||||
* Appends a timestamp suffix to avoid UNIQUE constraint collisions
|
||||
* with persistent D1 data from previous test runs.
|
||||
* Returns the JWT and full address string.
|
||||
*/
|
||||
export async function createTestAddress(
|
||||
ctx: APIRequestContext,
|
||||
name: string,
|
||||
domain: string = TEST_DOMAIN
|
||||
): Promise<{ jwt: string; address: string }> {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: uniqueName, 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a test email by exercising the real worker email() handler
|
||||
* via the admin test endpoint.
|
||||
*/
|
||||
export async function seedTestMail(
|
||||
ctx: APIRequestContext,
|
||||
address: string,
|
||||
opts: { subject?: string; html?: string; text?: string; from?: string }
|
||||
): Promise<void> {
|
||||
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_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (!body.success) {
|
||||
throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a mail via admin/send_mail, which saves to sendbox.
|
||||
*/
|
||||
export async function sendTestMail(
|
||||
ctx: APIRequestContext,
|
||||
fromAddress: string,
|
||||
opts: { to_mail: string; subject?: string; content?: string; is_html?: boolean }
|
||||
): Promise<void> {
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/send_mail`, {
|
||||
data: {
|
||||
from_name: '',
|
||||
from_mail: fromAddress,
|
||||
to_name: '',
|
||||
to_mail: opts.to_mail,
|
||||
subject: opts.subject || 'Test Sent Mail',
|
||||
content: opts.content || 'Sent mail body from E2E',
|
||||
is_html: opts.is_html ?? false,
|
||||
},
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to send mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all messages in Mailpit.
|
||||
*/
|
||||
export async function deleteAllMailpitMessages(ctx: APIRequestContext) {
|
||||
const res = await ctx.delete(`${MAILPIT_API}/v1/messages`);
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to delete Mailpit messages: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Mailpit WebSocket URL from the REST API URL.
|
||||
* MAILPIT_API is like "http://mailpit:8025/api" → ws://mailpit:8025/api/events
|
||||
*/
|
||||
function mailpitWsUrl(): string {
|
||||
return MAILPIT_API.replace(/^http/, 'ws') + '/events';
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a message matching `predicate` to arrive in Mailpit.
|
||||
*
|
||||
* Connects to Mailpit's WebSocket `/api/events` and listens for
|
||||
* `Type: "new"` events. When a matching message arrives, resolves
|
||||
* immediately — no polling, no arbitrary sleeps.
|
||||
*
|
||||
* Returns `{ ready, message }`:
|
||||
* - `ready` resolves when the WebSocket connection is open
|
||||
* - `message` resolves with the matched message summary
|
||||
*
|
||||
* Usage: await ready before triggering the send to avoid race conditions.
|
||||
*/
|
||||
export function onMailpitMessage(
|
||||
predicate: (msg: any) => boolean,
|
||||
{ timeout = 10_000 }: { timeout?: number } = {}
|
||||
): { ready: Promise<void>; message: Promise<any> } {
|
||||
let readyResolve: () => void;
|
||||
let readyReject: (err: Error) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
readyResolve = resolve;
|
||||
readyReject = reject;
|
||||
});
|
||||
|
||||
const message = new Promise<any>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const ws = new WebSocket(mailpitWsUrl());
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
if (!settled) { settled = true; reject(new Error('Mailpit message not received within timeout')); }
|
||||
}, timeout);
|
||||
|
||||
ws.on('open', () => readyResolve());
|
||||
|
||||
ws.on('message', (data: WebSocket.Data) => {
|
||||
try {
|
||||
const event = JSON.parse(data.toString());
|
||||
if (event.Type === 'new' && predicate(event.Data)) {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
if (!settled) { settled = true; resolve(event.Data); }
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
if (!settled) { settled = true; reject(new Error('Mailpit WebSocket closed before matching message')); }
|
||||
});
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
readyReject(err);
|
||||
if (!settled) { settled = true; reject(err); }
|
||||
});
|
||||
});
|
||||
|
||||
return { ready, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Request send mail access for an address.
|
||||
* Must be called before sending mail — creates the address_sender row
|
||||
* with the DEFAULT_SEND_BALANCE configured in the worker.
|
||||
*/
|
||||
export async function requestSendAccess(
|
||||
ctx: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<void> {
|
||||
const res = await ctx.post(`${WORKER_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to request send access: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a test address via its JWT.
|
||||
*/
|
||||
export async function deleteAddress(
|
||||
ctx: APIRequestContext,
|
||||
jwt: string
|
||||
): Promise<void> {
|
||||
const res = await ctx.delete(`${WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to delete address: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
32
e2e/fixtures/wrangler.toml.e2e
Normal file
32
e2e/fixtures/wrangler.toml.e2e
Normal file
@@ -0,0 +1,32 @@
|
||||
name = "cloudflare_temp_email"
|
||||
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
|
||||
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-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email"
|
||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||
410
e2e/package-lock.json
generated
Normal file
410
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,410 @@
|
||||
{
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.2.12",
|
||||
"nodemailer": "^8.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.12.tgz",
|
||||
"integrity": "sha512-UX8qCKXZk2xExe/x8KPTSbhROdtUGP13bSLSjT9Sb3YwGuryD4aFNlGhbWBW5B1GtgHMRxVv9yvl61RqXgIQtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.1",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.7"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"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==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
e2e/package.json
Normal file
19
e2e/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
|
||||
"test:down": "docker compose down -v"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.2.12",
|
||||
"nodemailer": "^8.0.1"
|
||||
}
|
||||
}
|
||||
35
e2e/playwright.config.ts
Normal file
35
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const WORKER_BASE = process.env.WORKER_URL!;
|
||||
const FRONTEND_BASE = process.env.FRONTEND_URL!;
|
||||
|
||||
export default defineConfig({
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['html', { open: 'never' }]],
|
||||
projects: [
|
||||
{
|
||||
name: 'api',
|
||||
testDir: './tests/api',
|
||||
use: {
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtp-proxy',
|
||||
testDir: './tests/smtp-proxy',
|
||||
use: {
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser',
|
||||
testDir: './tests/browser',
|
||||
use: {
|
||||
baseURL: FRONTEND_BASE,
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
48
e2e/scripts/docker-entrypoint.sh
Executable file
48
e2e/scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "==> Waiting for worker at $WORKER_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL/health_check" > /dev/null 2>&1; then
|
||||
echo " Worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||
echo " Frontend ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Frontend not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
|
||||
for i in $(seq 1 30); do
|
||||
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
|
||||
echo " smtp-proxy-tls SMTP ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 30 ]; then
|
||||
echo "WARNING: smtp-proxy-tls SMTP not ready after 30s, continuing anyway"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "==> Initializing database"
|
||||
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"
|
||||
|
||||
echo "==> Running Playwright tests"
|
||||
exec npx playwright test "$@"
|
||||
15
e2e/scripts/smtp-tls-entrypoint.sh
Executable file
15
e2e/scripts/smtp-tls-entrypoint.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CERT_DIR="/certs"
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
if [ ! -f "$CERT_DIR/cert.pem" ] || [ ! -f "$CERT_DIR/key.pem" ]; then
|
||||
echo "==> Generating self-signed TLS certificate"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \
|
||||
-days 1 -subj "/CN=smtp-proxy-tls"
|
||||
echo " Certificate generated"
|
||||
fi
|
||||
|
||||
exec python3 main.py
|
||||
31
e2e/tests/api/address-lifecycle.spec.ts
Normal file
31
e2e/tests/api/address-lifecycle.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, requestSendAccess } from '../../fixtures/test-helpers';
|
||||
|
||||
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');
|
||||
expect(address).toContain('@' + TEST_DOMAIN);
|
||||
expect(jwt).toBeTruthy();
|
||||
|
||||
// Request send access (creates address_sender row with DEFAULT_SEND_BALANCE)
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// Fetch address settings — balance should match DEFAULT_SEND_BALANCE=10
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.send_balance).toBe(10);
|
||||
|
||||
// Delete address
|
||||
await deleteAddress(request, jwt);
|
||||
|
||||
// Verify address is gone — settings should fail
|
||||
const afterDelete = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterDelete.ok()).toBe(false);
|
||||
});
|
||||
});
|
||||
59
e2e/tests/api/address-password.spec.ts
Normal file
59
e2e/tests/api/address-password.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Address Password Login', () => {
|
||||
test('set password then login with it', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-login');
|
||||
|
||||
try {
|
||||
// Set a password on the address
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'test-password-123' },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
const changePwdBody = await changePwdRes.json();
|
||||
expect(changePwdBody.success).toBe(true);
|
||||
|
||||
// Login with the correct password
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: 'test-password-123' },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const loginBody = await loginRes.json();
|
||||
expect(loginBody.jwt).toBeTruthy();
|
||||
expect(loginBody.address).toBe(address);
|
||||
|
||||
// The new JWT should work — verify by fetching settings
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${loginBody.jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('login with wrong password returns 401', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
|
||||
|
||||
try {
|
||||
// Set a password
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'correct-password' },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
const changePwdBody = await changePwdRes.json();
|
||||
expect(changePwdBody.success).toBe(true);
|
||||
|
||||
// Login with wrong password
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: 'wrong-password' },
|
||||
});
|
||||
expect(loginRes.status()).toBe(401);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
73
e2e/tests/api/auto-reply.spec.ts
Normal file
73
e2e/tests/api/auto-reply.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Auto Reply Settings', () => {
|
||||
test('get empty, save, then verify saved settings', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'auto-reply');
|
||||
|
||||
try {
|
||||
// GET auto_reply — should return empty object for new address
|
||||
const emptyRes = await request.get(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(emptyRes.ok()).toBe(true);
|
||||
const empty = await emptyRes.json();
|
||||
expect(Object.keys(empty)).toHaveLength(0);
|
||||
|
||||
// POST save auto_reply settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Test Bot',
|
||||
subject: 'Auto Reply',
|
||||
source_prefix: 'Re:',
|
||||
message: 'Thanks for your email!',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
const saveBody = await saveRes.json();
|
||||
expect(saveBody.success).toBe(true);
|
||||
|
||||
// GET auto_reply — should return saved settings
|
||||
const savedRes = await request.get(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(savedRes.ok()).toBe(true);
|
||||
const saved = await savedRes.json();
|
||||
expect(saved.name).toBe('Test Bot');
|
||||
expect(saved.subject).toBe('Auto Reply');
|
||||
expect(saved.source_prefix).toBe('Re:');
|
||||
expect(saved.message).toBe('Thanks for your email!');
|
||||
expect(saved.enabled).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('save with too long subject returns 400', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'auto-reply-long');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Bot',
|
||||
subject: 'x'.repeat(256),
|
||||
source_prefix: '',
|
||||
message: 'Hello',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.status()).toBe(400);
|
||||
const body = await saveRes.text();
|
||||
expect(body).toContain('too long');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
69
e2e/tests/api/clear-sent.spec.ts
Normal file
69
e2e/tests/api/clear-sent.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Clear Sent Items', () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
});
|
||||
|
||||
test('send mail then clear sent items', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'clear-sent');
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
try {
|
||||
const subject = `Clear Sent Test ${Date.now()}`;
|
||||
|
||||
// Listen before sending
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
// Send a mail
|
||||
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'Sender',
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: '<p>test</p>',
|
||||
is_html: true,
|
||||
},
|
||||
});
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
await listener.message;
|
||||
|
||||
// Verify sendbox has 1 item
|
||||
const listRes = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Clear sent items
|
||||
const clearRes = await request.delete(`${WORKER_URL}/api/clear_sent_items`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(clearRes.ok()).toBe(true);
|
||||
const clearBody = await clearRes.json();
|
||||
expect(clearBody.success).toBe(true);
|
||||
|
||||
// Verify sendbox is empty
|
||||
const afterRes = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
expect(after.results).toHaveLength(0);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
22
e2e/tests/api/health.spec.ts
Normal file
22
e2e/tests/api/health.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Health & Settings', () => {
|
||||
test('GET /health_check returns OK', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/health_check`);
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.text()).toBe('OK');
|
||||
});
|
||||
|
||||
test('GET /open_api/settings returns correct domains and sendMail enabled', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const settings = await res.json();
|
||||
expect(settings.domains).toContain('test.example.com');
|
||||
expect(settings.defaultDomains).toContain('test.example.com');
|
||||
expect(settings.enableSendMail).toBe(true);
|
||||
expect(settings.enableUserCreateEmail).toBe(true);
|
||||
expect(settings.enableUserDeleteEmail).toBe(true);
|
||||
});
|
||||
});
|
||||
80
e2e/tests/api/mail-deletion.spec.ts
Normal file
80
e2e/tests/api/mail-deletion.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Mail Deletion', () => {
|
||||
test('delete a single mail by ID', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'del-single');
|
||||
|
||||
try {
|
||||
// Seed 3 emails
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await seedTestMail(request, address, { subject: `Mail ${i}` });
|
||||
}
|
||||
|
||||
// List mails — should have 3
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(3);
|
||||
|
||||
// Delete the second mail
|
||||
const targetId = results[1].id;
|
||||
const delRes = await request.delete(`${WORKER_URL}/api/mails/${targetId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(delRes.ok()).toBe(true);
|
||||
const delBody = await delRes.json();
|
||||
expect(delBody.success).toBe(true);
|
||||
|
||||
// List again — should have 2, and the deleted ID should be gone
|
||||
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
expect(after.results).toHaveLength(2);
|
||||
expect(after.results.every((m: any) => m.id !== targetId)).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('clear entire inbox', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'del-clear');
|
||||
|
||||
try {
|
||||
// Seed 3 emails
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await seedTestMail(request, address, { subject: `Mail ${i}` });
|
||||
}
|
||||
|
||||
// Verify 3 mails exist
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(3);
|
||||
|
||||
// Clear inbox
|
||||
const clearRes = await request.delete(`${WORKER_URL}/api/clear_inbox`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(clearRes.ok()).toBe(true);
|
||||
const clearBody = await clearRes.json();
|
||||
expect(clearBody.success).toBe(true);
|
||||
|
||||
// Verify inbox is empty
|
||||
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
expect(after.results).toHaveLength(0);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
55
e2e/tests/api/mail-detail.spec.ts
Normal file
55
e2e/tests/api/mail-detail.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Mail Detail', () => {
|
||||
test('fetch a single mail by ID', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'detail-get');
|
||||
|
||||
try {
|
||||
// Seed a mail with known content
|
||||
await seedTestMail(request, address, {
|
||||
subject: 'Detail Test',
|
||||
from: 'alice@test.example.com',
|
||||
html: '<p>Hello detail</p>',
|
||||
text: 'Hello detail',
|
||||
});
|
||||
|
||||
// List mails to get the ID
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
// Fetch single mail by ID
|
||||
const detailRes = await request.get(`${WORKER_URL}/api/mail/${mailId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.id).toBe(mailId);
|
||||
expect(mail.address).toBe(address);
|
||||
expect(mail.source).toBe('alice@test.example.com');
|
||||
expect(mail.raw).toContain('Detail Test');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetch non-existent mail returns null', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'detail-404');
|
||||
|
||||
try {
|
||||
const res = await request.get(`${WORKER_URL}/api/mail/99999999`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body).toBeNull();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
31
e2e/tests/api/send-access.spec.ts
Normal file
31
e2e/tests/api/send-access.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, requestSendAccess, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Send Access', () => {
|
||||
test('request send access succeeds once, duplicate returns 400', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'send-access');
|
||||
|
||||
try {
|
||||
// First request — should succeed
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// Verify balance is set via settings
|
||||
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
const settings = await settingsRes.json();
|
||||
expect(settings.send_balance).toBe(10);
|
||||
|
||||
// Duplicate request — should fail with 400
|
||||
const dupRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(dupRes.status()).toBe(400);
|
||||
const dupBody = await dupRes.text();
|
||||
expect(dupBody).toContain('Already');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
51
e2e/tests/api/send-mail.spec.ts
Normal file
51
e2e/tests/api/send-mail.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
WORKER_URL,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Send Mail via SMTP', () => {
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
});
|
||||
|
||||
test('send HTML email and verify in Mailpit', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'sender-test');
|
||||
|
||||
// Must request send access before sending (creates address_sender row)
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
const subject = `E2E Test ${Date.now()}`;
|
||||
const htmlContent = '<h1>Hello</h1><p>This is an <b>E2E test</b> email.</p>';
|
||||
|
||||
// Start listening for the message BEFORE sending
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
// Send mail via worker API
|
||||
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
from_name: 'E2E Sender',
|
||||
to_name: 'E2E Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: htmlContent,
|
||||
is_html: true,
|
||||
},
|
||||
});
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
|
||||
// Wait for Mailpit WebSocket "new" event — no polling
|
||||
const mail = await listener.message;
|
||||
expect(mail.From.Address).toBe(address);
|
||||
expect(mail.To[0].Address).toBe('recipient@test.example.com');
|
||||
|
||||
// Cleanup
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
});
|
||||
88
e2e/tests/api/webhook-settings.spec.ts
Normal file
88
e2e/tests/api/webhook-settings.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
seedTestMail,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Webhook Settings', () => {
|
||||
test('get default webhook settings returns empty/disabled', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'webhook-get');
|
||||
|
||||
try {
|
||||
const res = await request.get(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enabled).toBeFalsy();
|
||||
expect(settings.url).toBe('');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('save and retrieve webhook settings', async ({ request }) => {
|
||||
const { jwt } = await createTestAddress(request, 'webhook-save');
|
||||
|
||||
try {
|
||||
// Save webhook settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: true,
|
||||
url: 'https://example.com/webhook',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ from: '${from}', subject: '${subject}' }),
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
const saveBody = await saveRes.json();
|
||||
expect(saveBody.success).toBe(true);
|
||||
|
||||
// Retrieve and verify
|
||||
const getRes = await request.get(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enabled).toBe(true);
|
||||
expect(settings.url).toBe('https://example.com/webhook');
|
||||
expect(settings.method).toBe('POST');
|
||||
expect(settings.headers).toBe(JSON.stringify({ 'Content-Type': 'application/json' }));
|
||||
expect(settings.body).toBe(JSON.stringify({ from: '${from}', subject: '${subject}' }));
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('test webhook with unreachable URL returns error', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'webhook-fail');
|
||||
|
||||
try {
|
||||
// Seed a mail so the test endpoint has raw data
|
||||
await seedTestMail(request, address, {
|
||||
subject: 'Webhook Fail Test',
|
||||
from: 'sender@test.example.com',
|
||||
text: 'This webhook should fail',
|
||||
});
|
||||
|
||||
// Test webhook with unreachable URL — expect non-2xx response
|
||||
const testRes = await request.post(`${WORKER_URL}/api/webhook/test`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: true,
|
||||
url: 'http://unreachable.invalid/webhook',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ from: '${from}' }),
|
||||
},
|
||||
});
|
||||
expect(testRes.ok()).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
161
e2e/tests/api/webhook-trigger.spec.ts
Normal file
161
e2e/tests/api/webhook-trigger.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
/**
|
||||
* Start a temporary HTTP server that records incoming requests.
|
||||
* Returns the server, a promise that resolves with the first request body,
|
||||
* and the URL to use as webhook target.
|
||||
*/
|
||||
async function startWebhookReceiver(): Promise<{
|
||||
server: http.Server;
|
||||
firstRequest: Promise<{ body: string; method: string; path: string; headers: http.IncomingHttpHeaders }>;
|
||||
url: string;
|
||||
}> {
|
||||
let resolve: (val: any) => void;
|
||||
const firstRequest = new Promise<any>((r) => { resolve = r; });
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
resolve({
|
||||
body: Buffer.concat(chunks).toString('utf-8'),
|
||||
method: req.method || '',
|
||||
path: req.url || '',
|
||||
headers: req.headers,
|
||||
});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
});
|
||||
|
||||
// Use port 0 to let the OS assign a free port
|
||||
await new Promise<void>((resolve) => server.listen(0, '0.0.0.0', resolve));
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('Failed to resolve webhook receiver port');
|
||||
const boundPort = addr.port;
|
||||
// In Docker network, e2e-runner container hostname is "e2e-runner"
|
||||
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
|
||||
return { server, firstRequest, url: `http://${hostname}:${boundPort}/webhook` };
|
||||
}
|
||||
|
||||
test.describe('Webhook — triggered on incoming mail', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
({ jwt, address } = await createTestAddress(request, 'webhook-trigger'));
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('webhook is called with correct payload when mail arrives', async ({ request }) => {
|
||||
const { server, firstRequest, url } = await startWebhookReceiver();
|
||||
|
||||
try {
|
||||
// Configure user webhook
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: true,
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
from: '${from}',
|
||||
to: '${to}',
|
||||
subject: '${subject}',
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Send incoming mail via receive_mail endpoint
|
||||
const from = `webhook-sender@test.example.com`;
|
||||
const subject = `Webhook Test ${Date.now()}`;
|
||||
const messageId = `<webhook-${Date.now()}@test>`;
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Webhook trigger test body`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
// Wait for webhook to be called
|
||||
const received = await firstRequest;
|
||||
expect(received.method).toBe('POST');
|
||||
expect(received.path).toBe('/webhook');
|
||||
|
||||
const payload = JSON.parse(received.body);
|
||||
expect(payload.from).toContain('webhook-sender@test.example.com');
|
||||
expect(payload.to).toBe(address);
|
||||
expect(payload.subject).toBe(subject);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('webhook is NOT called when disabled', async ({ request }) => {
|
||||
const { server, firstRequest, url } = await startWebhookReceiver();
|
||||
|
||||
try {
|
||||
// Disable webhook
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
enabled: false,
|
||||
url,
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ from: '${from}' }),
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Send incoming mail
|
||||
const subject = `Webhook Disabled ${Date.now()}`;
|
||||
const messageId = `<webhook-off-${Date.now()}@test>`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Should not trigger webhook`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to: address, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
// Webhook should NOT be called — wait briefly then verify timeout
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('timeout')), 3_000)
|
||||
);
|
||||
await expect(
|
||||
Promise.race([firstRequest, timeoutPromise])
|
||||
).rejects.toThrow('timeout');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
49
e2e/tests/browser/inbox.spec.ts
Normal file
49
e2e/tests/browser/inbox.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
seedTestMail,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
|
||||
test.describe('Inbox Browser Flow', () => {
|
||||
test('login via JWT, view inbox, open email', async ({ page }) => {
|
||||
// Create API context for setup
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'inbox-browser');
|
||||
jwt = created.jwt;
|
||||
const address = created.address;
|
||||
|
||||
// Seed an email
|
||||
const subject = `Browser Test ${Date.now()}`;
|
||||
await seedTestMail(api, address, {
|
||||
subject,
|
||||
html: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
|
||||
});
|
||||
|
||||
// Login via JWT query param with /en/ path to force English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// The mail subject should be visible in the inbox list item
|
||||
const mailItem = page.getByRole('listitem').getByText(subject);
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click to open the email
|
||||
await mailItem.click();
|
||||
|
||||
// Verify the email detail panel shows the subject as a heading
|
||||
// (n-card-header wraps n-card-header__main, both match heading role — use .first())
|
||||
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 });
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
105
e2e/tests/browser/reply-html.spec.ts
Normal file
105
e2e/tests/browser/reply-html.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
seedTestMail,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
} from '../../fixtures/test-helpers';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
|
||||
test.describe('Reply HTML & XSS Sanitization', () => {
|
||||
test('reply to HTML email — XSS payloads stripped, HTML preserved', async ({ page }) => {
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
await deleteAllMailpitMessages(api);
|
||||
|
||||
const created = await createTestAddress(api, 'reply-xss');
|
||||
jwt = created.jwt;
|
||||
const address = created.address;
|
||||
|
||||
// Request send access so Reply can navigate to compose form
|
||||
await requestSendAccess(api, jwt);
|
||||
|
||||
// Seed email with XSS payloads embedded in HTML
|
||||
const xssHtml = [
|
||||
'<div>',
|
||||
' <h1>Important Message</h1>',
|
||||
' <p>Please review this content.</p>',
|
||||
' <script>alert("xss")</script>',
|
||||
' <img src=x onerror="alert(1)">',
|
||||
' <a href="javascript:alert(2)">click me</a>',
|
||||
' <p style="color:red">Styled paragraph</p>',
|
||||
'</div>',
|
||||
].join('\n');
|
||||
|
||||
await seedTestMail(api, address, {
|
||||
subject: 'XSS Test Email',
|
||||
html: xssHtml,
|
||||
from: 'attacker@test.example.com',
|
||||
});
|
||||
|
||||
// Single dialog handler with phase tracking.
|
||||
// During email rendering, the mail viewer uses an unsandboxed iframe so
|
||||
// inline event handlers like onerror may fire — we dismiss those.
|
||||
// After clicking Reply, any dialog means the compose path failed to sanitize.
|
||||
let inComposePhase = false;
|
||||
let composeDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
if (inComposePhase) composeDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
|
||||
// Login with English locale
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// Open the email (use listitem to avoid strict mode violation
|
||||
// when detail panel also shows the subject)
|
||||
const mailItem = page.getByRole('listitem').getByText('XSS Test Email');
|
||||
await expect(mailItem).toBeVisible({ timeout: 10_000 });
|
||||
await mailItem.click();
|
||||
|
||||
// Wait for Reply button to appear — signals email content has rendered
|
||||
const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first();
|
||||
await expect(replyButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click Reply — from here on, dialogs indicate sanitization failure (#857)
|
||||
inComposePhase = true;
|
||||
await replyButton.click();
|
||||
|
||||
// In the reply compose area, check that the forwarded HTML is sanitized:
|
||||
// - <script> tags should be removed
|
||||
// - onerror attributes should be removed
|
||||
// - javascript: URLs should be removed
|
||||
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
|
||||
await expect(composeArea).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
|
||||
// fall back to innerHTML() for contenteditable elements
|
||||
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
|
||||
const content = tagName === 'textarea'
|
||||
? await composeArea.inputValue()
|
||||
: await composeArea.innerHTML();
|
||||
|
||||
// Verify content is non-empty (guard against vacuous pass)
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
// XSS vectors must be stripped
|
||||
expect(content).not.toContain('<script>');
|
||||
expect(content).not.toContain('onerror');
|
||||
expect(content).not.toContain('javascript:');
|
||||
|
||||
// No XSS dialog should have fired in the compose area
|
||||
expect(composeDialogAppeared).toBe(false);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
110
e2e/tests/browser/webhook-presets.spec.ts
Normal file
110
e2e/tests/browser/webhook-presets.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { FRONTEND_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
|
||||
test.describe('Webhook Presets', () => {
|
||||
test('selecting each preset fills valid settings', async ({ page, context }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
// Block popups (presets open doc URLs in new tabs)
|
||||
context.on('page', (p) => p.close());
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'webhook-preset');
|
||||
jwt = created.jwt;
|
||||
|
||||
// Login via JWT
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
// Click "Webhook Settings" in the sidebar menu
|
||||
const webhookMenu = page.getByText('Webhook Settings');
|
||||
await expect(webhookMenu).toBeVisible({ timeout: 10_000 });
|
||||
await webhookMenu.click();
|
||||
|
||||
// Verify presets button is visible
|
||||
const presetsBtn = page.getByRole('button', { name: 'Presets' });
|
||||
await expect(presetsBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Helper to get form field value by label text
|
||||
const getFieldValue = async (label: string): Promise<string> => {
|
||||
// Find the label, then get the sibling textbox in the same form row
|
||||
const row = page.locator('div', { hasText: new RegExp(`^${label}$`) }).locator('..');
|
||||
const textbox = row.getByRole('textbox');
|
||||
return textbox.inputValue();
|
||||
};
|
||||
|
||||
// Define expected presets and their key fields
|
||||
const expectedPresets = [
|
||||
{
|
||||
name: 'Message Pusher',
|
||||
urlPattern: 'msgpusher.com',
|
||||
bodyKeys: ['token', 'title', 'description', 'content'],
|
||||
},
|
||||
{
|
||||
name: 'Bark',
|
||||
urlPattern: 'api.day.app',
|
||||
bodyKeys: ['title', 'body', 'group'],
|
||||
},
|
||||
{
|
||||
name: 'ntfy',
|
||||
urlPattern: 'ntfy.sh',
|
||||
bodyKeys: ['topic', 'title', 'message', 'tags'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const preset of expectedPresets) {
|
||||
// Open dropdown and select preset
|
||||
await presetsBtn.click();
|
||||
const option = page.locator('.n-dropdown-option', { hasText: preset.name });
|
||||
await expect(option).toBeVisible({ timeout: 5_000 });
|
||||
await option.click();
|
||||
// Wait for dropdown to close, then for URL field to contain preset pattern
|
||||
await expect(option).toBeHidden({ timeout: 5_000 });
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const allTextboxes = page.getByRole('textbox');
|
||||
const count = await allTextboxes.count();
|
||||
|
||||
// Find URL, HEADERS, BODY values by reading all textboxes
|
||||
let urlValue = '';
|
||||
let headersValue = '';
|
||||
let bodyValue = '';
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const val = await allTextboxes.nth(i).inputValue();
|
||||
if (val.includes(preset.urlPattern)) {
|
||||
urlValue = val;
|
||||
} else if (val.includes('Content-Type')) {
|
||||
headersValue = val;
|
||||
} else if (val.includes('${subject}')) {
|
||||
bodyValue = val;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify URL
|
||||
expect(urlValue, `${preset.name}: URL should contain ${preset.urlPattern}`).toContain(preset.urlPattern);
|
||||
|
||||
// Verify HEADERS is valid JSON with Content-Type
|
||||
expect(headersValue, `${preset.name}: HEADERS should not be empty`).toBeTruthy();
|
||||
const headers = JSON.parse(headersValue);
|
||||
expect(headers, `${preset.name}: headers should have Content-Type`).toHaveProperty('Content-Type', 'application/json');
|
||||
|
||||
// Verify BODY is valid JSON with expected keys
|
||||
expect(bodyValue, `${preset.name}: BODY should not be empty`).toBeTruthy();
|
||||
const body = JSON.parse(bodyValue);
|
||||
for (const key of preset.bodyKeys) {
|
||||
expect(body, `${preset.name}: body should have key "${key}"`).toHaveProperty(key);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
274
e2e/tests/smtp-proxy/imap-proxy.spec.ts
Normal file
274
e2e/tests/smtp-proxy/imap-proxy.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { createTestAddress, seedTestMail, sendTestMail, deleteAddress, deleteAllMailpitMessages, onMailpitMessage } from '../../fixtures/test-helpers';
|
||||
|
||||
const IMAP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
|
||||
const IMAP_PORT = parseInt(process.env.SMTP_PROXY_IMAP_PORT || '11143', 10);
|
||||
|
||||
function createClient(user: string, pass: string) {
|
||||
return new ImapFlow({
|
||||
host: IMAP_HOST,
|
||||
port: IMAP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('IMAP Proxy', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
({ jwt, address } = await createTestAddress(request, 'imap-e2e'));
|
||||
await seedTestMail(request, address, { subject: 'IMAP Test 1', text: 'First test email' });
|
||||
await seedTestMail(request, address, { subject: 'IMAP Test 2', text: 'Second test email' });
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('login with JWT token', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
expect(client.usable).toBe(true);
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('login with wrong password fails', async () => {
|
||||
const client = createClient(address, 'wrong-password');
|
||||
await expect(client.connect()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('LIST returns INBOX', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
const names = mailboxes.map(m => m.path);
|
||||
expect(names).toContain('INBOX');
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SELECT INBOX returns message count', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
expect(client.mailbox).toBeTruthy();
|
||||
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('STATUS returns MESSAGES and UIDNEXT', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const status = await client.status('INBOX', { messages: true, uidNext: true });
|
||||
expect(status.messages).toBeGreaterThanOrEqual(2);
|
||||
expect(status.uidNext).toBeGreaterThan(0);
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH headers returns Subject', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { headers: true });
|
||||
const headers = msg.headers.toString();
|
||||
expect(headers).toContain('Subject:');
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH body returns content', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true });
|
||||
expect(msg.source.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SEARCH ALL returns message numbers', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const results = await client.search({ all: true });
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('STORE sets flags', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const result = await client.messageFlagsAdd('1', ['\\Seen']);
|
||||
expect(result).toBe(true);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('UID FETCH works', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const results = await client.search({ all: true });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
const msg = await client.fetchOne(String(results[0]), { uid: true, flags: true }, { uid: true });
|
||||
expect(msg.uid).toBe(results[0]);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH source contains valid MIME with Content-Type and seeded body', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true, envelope: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(source).toContain('Content-Type:');
|
||||
expect(source).toContain('Subject:');
|
||||
// No duplicate From headers (regression: getBodyFile returned full MIME)
|
||||
const fromMatches = source.match(/^From:/gm);
|
||||
expect(fromMatches).toHaveLength(1);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('RFC822.SIZE matches actual source length', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true, size: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(msg.size).toBe(Buffer.byteLength(source, 'utf-8'));
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH all messages returns correct sequence numbers', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const messages: { seq: number; uid: number }[] = [];
|
||||
for await (const msg of client.fetch('1:*', { uid: true })) {
|
||||
messages.push({ seq: msg.seq, uid: msg.uid });
|
||||
}
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
// Sequence numbers must be consecutive starting from 1
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
expect(messages[i].seq).toBe(i + 1);
|
||||
}
|
||||
// UIDs must be strictly ascending
|
||||
for (let i = 1; i < messages.length; i++) {
|
||||
expect(messages[i].uid).toBeGreaterThan(messages[i - 1].uid);
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('LIST returns SENT mailbox', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
const names = mailboxes.map(m => m.path);
|
||||
expect(names).toContain('SENT');
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SELECT INBOX includes UIDVALIDITY and UIDNEXT', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
expect(client.mailbox!.uidValidity).toBeGreaterThan(0);
|
||||
expect(client.mailbox!.uidNext).toBeGreaterThan(0);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IMAP Proxy — SENT mailbox', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
const sentSubject = `IMAP Sent Test ${Date.now()}`;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
({ jwt, address } = await createTestAddress(request, 'imap-sent'));
|
||||
|
||||
const listener = onMailpitMessage((m) => m.Subject === sentSubject);
|
||||
await listener.ready;
|
||||
|
||||
await sendTestMail(request, address, {
|
||||
to_mail: `recipient@test.example.com`,
|
||||
subject: sentSubject,
|
||||
content: 'E2E sent mail body',
|
||||
});
|
||||
await listener.message;
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('SELECT SENT returns message count', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('SENT');
|
||||
try {
|
||||
expect(client.mailbox).toBeTruthy();
|
||||
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH SENT source contains valid MIME', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('SENT');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true, envelope: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(source.length).toBeGreaterThan(50);
|
||||
expect(source).toContain('Content-Type:');
|
||||
expect(source).toContain('Subject:');
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
});
|
||||
81
e2e/tests/smtp-proxy/imap-tls.spec.ts
Normal file
81
e2e/tests/smtp-proxy/imap-tls.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import { createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const IMAP_TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls';
|
||||
const IMAP_TLS_PORT = parseInt(process.env.SMTP_PROXY_TLS_IMAP_PORT || '11144', 10);
|
||||
|
||||
function createClient(user: string, pass: string) {
|
||||
return new ImapFlow({
|
||||
host: IMAP_TLS_HOST,
|
||||
port: IMAP_TLS_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('IMAP Proxy — STARTTLS', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
({ jwt, address } = await createTestAddress(request, 'imap-tls'));
|
||||
await seedTestMail(request, address, { subject: 'IMAP TLS Test 1', text: 'First TLS test email' });
|
||||
await seedTestMail(request, address, { subject: 'IMAP TLS Test 2', text: 'Second TLS test email' });
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('login with JWT over STARTTLS', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
expect(client.usable).toBe(true);
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('login with wrong password fails over STARTTLS', async () => {
|
||||
const client = createClient(address, 'wrong-password');
|
||||
await expect(client.connect()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('LIST returns INBOX over STARTTLS', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const mailboxes = await client.list();
|
||||
const names = mailboxes.map(m => m.path);
|
||||
expect(names).toContain('INBOX');
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('SELECT INBOX returns messages over STARTTLS', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
expect(client.mailbox).toBeTruthy();
|
||||
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
|
||||
test('FETCH source over STARTTLS contains valid MIME', async () => {
|
||||
const client = createClient(address, jwt);
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
const msg = await client.fetchOne('1', { source: true });
|
||||
const source = msg.source.toString('utf-8');
|
||||
expect(source).toContain('Content-Type:');
|
||||
expect(source).toContain('Subject:');
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
await client.logout();
|
||||
});
|
||||
});
|
||||
112
e2e/tests/smtp-proxy/smtp-proxy.spec.ts
Normal file
112
e2e/tests/smtp-proxy/smtp-proxy.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
WORKER_URL,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const SMTP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PROXY_SMTP_PORT || '8025', 10);
|
||||
|
||||
function createTransport(user: string, pass: string) {
|
||||
return nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('SMTP Proxy', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
({ jwt, address } = await createTestAddress(request, 'smtp-e2e'));
|
||||
await requestSendAccess(request, jwt);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('send plain text email via SMTP', async ({ request }) => {
|
||||
const subject = `SMTP Plain ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Hello from SMTP E2E test',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('send HTML email via SMTP', async ({ request }) => {
|
||||
const subject = `SMTP HTML ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
html: '<h1>Hello</h1><p>HTML E2E test</p>',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('auth with wrong password fails', async () => {
|
||||
const transport = createTransport(address, 'wrong-password');
|
||||
await expect(
|
||||
transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject: 'Should fail',
|
||||
text: 'This should not be sent',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('sent mail appears in sendbox API', async ({ request }) => {
|
||||
const subject = `SMTP Sendbox ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTransport(address, jwt);
|
||||
await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Check sendbox',
|
||||
});
|
||||
await listener.message;
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
const found = results.some((r: any) => {
|
||||
const raw = JSON.parse(r.raw);
|
||||
return raw.subject === subject;
|
||||
});
|
||||
expect(found).toBe(true);
|
||||
});
|
||||
});
|
||||
114
e2e/tests/smtp-proxy/smtp-tls.spec.ts
Normal file
114
e2e/tests/smtp-proxy/smtp-tls.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import nodemailer from 'nodemailer';
|
||||
import {
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
requestSendAccess,
|
||||
onMailpitMessage,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls';
|
||||
const TLS_SMTP_PORT = parseInt(process.env.SMTP_PROXY_TLS_SMTP_PORT || '8026', 10);
|
||||
|
||||
function createTlsTransport(user: string, pass: string) {
|
||||
return nodemailer.createTransport({
|
||||
host: TLS_HOST,
|
||||
port: TLS_SMTP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
requireTLS: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createNoTlsTransport(user: string, pass: string) {
|
||||
return nodemailer.createTransport({
|
||||
host: TLS_HOST,
|
||||
port: TLS_SMTP_PORT,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
tls: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('SMTP Proxy — STARTTLS', () => {
|
||||
let jwt: string;
|
||||
let address: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
await deleteAllMailpitMessages(request);
|
||||
({ jwt, address } = await createTestAddress(request, 'smtp-tls'));
|
||||
await requestSendAccess(request, jwt);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
await deleteAddress(request, jwt);
|
||||
});
|
||||
|
||||
test('send plain text email via STARTTLS', async () => {
|
||||
const subject = `SMTP TLS Plain ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTlsTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Hello from SMTP STARTTLS E2E test',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('send HTML email via STARTTLS', async () => {
|
||||
const subject = `SMTP TLS HTML ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createTlsTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
html: '<h1>Hello</h1><p>STARTTLS HTML E2E test</p>',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('connection without STARTTLS still works', async () => {
|
||||
const subject = `SMTP TLS NoForce ${Date.now()}`;
|
||||
const listener = onMailpitMessage((m) => m.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const transport = createNoTlsTransport(address, jwt);
|
||||
const info = await transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject,
|
||||
text: 'Hello without forced STARTTLS',
|
||||
});
|
||||
expect(info.accepted).toContain('recipient@test.example.com');
|
||||
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.Subject).toBe(subject);
|
||||
});
|
||||
|
||||
test('auth with wrong password fails over STARTTLS', async () => {
|
||||
const transport = createTlsTransport(address, 'wrong-password');
|
||||
await expect(
|
||||
transport.sendMail({
|
||||
from: address,
|
||||
to: 'recipient@test.example.com',
|
||||
subject: 'Should fail',
|
||||
text: 'This should not be sent',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,22 +17,25 @@
|
||||
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
|
||||
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
|
||||
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
|
||||
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
|
||||
"deploy:actions": "npm run build && wrangler pages deploy ./dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@fingerprintjs/fingerprintjs": "^5.1.0",
|
||||
"@simplewebauthn/browser": "10.0.0",
|
||||
"@unhead/vue": "^2.1.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.13.6",
|
||||
"dompurify": "^3.3.2",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.1",
|
||||
"naive-ui": "^2.43.2",
|
||||
"postal-mime": "^2.7.3",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.27",
|
||||
"vue": "^3.5.29",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^4.6.4"
|
||||
@@ -40,16 +43,18 @@
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"jsdom": "^28.1.0",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-top-level-await": "^1.6.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"wrangler": "^4.59.2"
|
||||
"wrangler": "^4.70.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
2899
frontend/pnpm-lock.yaml
generated
2899
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
enableWebhook: res["enableWebhook"] || false,
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||
statusUrl: res["statusUrl"] || "",
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem } from '../utils/email-parser'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
|
||||
import MailContentRenderer from "./MailContentRenderer.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
|
||||
@@ -147,6 +148,7 @@ const { t } = useI18n({
|
||||
attachments: 'Show Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
pleaseSelectMail: "Please select mail",
|
||||
emptyInbox: "Your inbox is empty",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
reply: 'Reply',
|
||||
@@ -171,6 +173,7 @@ const { t } = useI18n({
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择邮件",
|
||||
emptyInbox: "收件箱为空",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
reply: '回复',
|
||||
@@ -274,30 +277,12 @@ const deleteMail = async () => {
|
||||
};
|
||||
|
||||
const replyMail = async () => {
|
||||
const emailRegex = /(.+?) <(.+?)>/;
|
||||
let toMail = curMail.value.originalSource;
|
||||
let toName = ""
|
||||
const match = emailRegex.exec(curMail.value.source);
|
||||
if (match) {
|
||||
toName = match[1];
|
||||
toMail = match[2];
|
||||
}
|
||||
Object.assign(sendMailModel.value, {
|
||||
toName: toName,
|
||||
toMail: toMail,
|
||||
subject: `${t('reply')}: ${curMail.value.subject}`,
|
||||
contentType: 'rich',
|
||||
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
|
||||
});
|
||||
Object.assign(sendMailModel.value, buildReplyModel(curMail.value, t('reply')));
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
const forwardMail = async () => {
|
||||
Object.assign(sendMailModel.value, {
|
||||
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
|
||||
contentType: curMail.value.message ? 'html' : 'text',
|
||||
content: curMail.value.message || curMail.value.text,
|
||||
});
|
||||
Object.assign(sendMailModel.value, buildForwardModel(curMail.value, t('forwardMail')));
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
@@ -446,7 +431,7 @@ onBeforeUnmount(() => {
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 50vh; max-height: 100vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
@@ -506,7 +491,10 @@ onBeforeUnmount(() => {
|
||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
<n-result status="info" :title="count === 0 ? t('emptyInbox') : t('pleaseSelectMail')">
|
||||
<template #icon>
|
||||
<n-icon :component="InboxRound" :size="100" />
|
||||
</template>
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -531,7 +519,7 @@ onBeforeUnmount(() => {
|
||||
<n-input v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate } = useGlobalState();
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
@@ -184,22 +184,22 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
<AiExtractInfo :metadata="mail.metadata" />
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<div class="mail-content">
|
||||
<div class="mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
|
||||
style="height: 100vh;">
|
||||
<n-drawer-content :title="mail.subject" closable>
|
||||
<div class="fullscreen-mail-content">
|
||||
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
@@ -259,6 +259,10 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.dark-mode .mail-text {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.mail-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -266,6 +270,10 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.dark-mode .mail-iframe {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.mail-html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { SendRound } from '@vicons/material'
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -52,6 +53,7 @@ const { t } = useI18n({
|
||||
refresh: 'Refresh',
|
||||
showCode: 'Change View Original Code',
|
||||
pleaseSelectMail: "Please select a mail to view.",
|
||||
emptySent: "No sent emails",
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
multiAction: 'Multi Action',
|
||||
@@ -64,6 +66,7 @@ const { t } = useI18n({
|
||||
refresh: '刷新',
|
||||
showCode: '切换查看元数据',
|
||||
pleaseSelectMail: "请选择一封邮件查看。",
|
||||
emptySent: "发件箱为空",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
multiAction: '多选',
|
||||
@@ -239,7 +242,7 @@ onMounted(async () => {
|
||||
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
@@ -297,7 +300,10 @@ onMounted(async () => {
|
||||
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
<n-result status="info" :title="t('pleaseSelectMail')">
|
||||
<n-result status="info" :title="count === 0 ? t('emptySent') : t('pleaseSelectMail')">
|
||||
<template #icon>
|
||||
<n-icon :component="SendRound" :size="100" />
|
||||
</template>
|
||||
</n-result>
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -312,7 +318,7 @@ onMounted(async () => {
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-thing :title="row.subject">
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const shadowHost = ref(null);
|
||||
@@ -40,7 +44,13 @@ const renderShadowDom = () => {
|
||||
|
||||
// Update content if Shadow DOM exists
|
||||
if (shadowRoot) {
|
||||
shadowRoot.innerHTML = props.htmlContent;
|
||||
const darkModeStyle = props.isDark
|
||||
? `<style>
|
||||
:host { color: #e0e0e0; }
|
||||
a { color: #A8C7FA; }
|
||||
</style>`
|
||||
: '';
|
||||
shadowRoot.innerHTML = darkModeStyle + props.htmlContent;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to render Shadow DOM, falling back to v-html:', error);
|
||||
@@ -68,8 +78,8 @@ onBeforeUnmount(() => {
|
||||
shadowRoot = null;
|
||||
});
|
||||
|
||||
// Update Shadow DOM when htmlContent changes
|
||||
watch(() => props.htmlContent, () => {
|
||||
// Update Shadow DOM when htmlContent or dark mode changes
|
||||
watch(() => [props.htmlContent, props.isDark], () => {
|
||||
renderShadowDom();
|
||||
}, { flush: 'post' });
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, h } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { DropdownOption } from 'naive-ui'
|
||||
|
||||
const props = defineProps({
|
||||
fetchData: {
|
||||
@@ -32,8 +33,7 @@ const { t } = useI18n({
|
||||
notEnabled: 'Webhook is not enabled for you',
|
||||
urlMissing: 'URL is required',
|
||||
enable: 'Enable',
|
||||
messagePusherDemo: 'Fill with Message Pusher Demo',
|
||||
messagePusherDoc: 'Message Pusher Doc',
|
||||
presets: 'Presets',
|
||||
fillInDemoTip: 'Please modify the URL and other settings to your own',
|
||||
},
|
||||
zh: {
|
||||
@@ -43,8 +43,7 @@ const { t } = useI18n({
|
||||
notEnabled: 'Webhook 未开启,请联系管理员开启',
|
||||
urlMissing: 'URL 不能为空',
|
||||
enable: '启用',
|
||||
messagePusherDemo: '填入MessagePusher示例',
|
||||
messagePusherDoc: 'MessagePusher文档',
|
||||
presets: '示例模板',
|
||||
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
|
||||
}
|
||||
}
|
||||
@@ -58,26 +57,82 @@ class WebhookSettings {
|
||||
body: string = JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
|
||||
interface WebhookPreset {
|
||||
name: string
|
||||
doc: string
|
||||
settings: WebhookSettings
|
||||
}
|
||||
|
||||
const messagePusherDemo = {
|
||||
enabled: true,
|
||||
url: 'https://msgpusher.com/push/username',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"token": "token",
|
||||
"title": "${subject}",
|
||||
"description": "${subject}",
|
||||
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
|
||||
}, null, 2),
|
||||
} as WebhookSettings;
|
||||
const presets: WebhookPreset[] = [
|
||||
{
|
||||
name: 'Message Pusher',
|
||||
doc: 'https://github.com/songquanpeng/message-pusher',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://msgpusher.com/push/username',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"token": "token",
|
||||
"title": "${subject}",
|
||||
"description": "${subject}",
|
||||
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Bark',
|
||||
doc: 'https://github.com/Finb/Bark',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://api.day.app/YOUR_KEY',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"title": "${subject}",
|
||||
"body": "From: ${from}\nTo: ${to}\n\n${parsedText}",
|
||||
"group": "email"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ntfy',
|
||||
doc: 'https://docs.ntfy.sh/publish/',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://ntfy.sh/YOUR_TOPIC',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"topic": "YOUR_TOPIC",
|
||||
"title": "${subject}",
|
||||
"message": "From: ${from}\nTo: ${to}\n\n${parsedText}",
|
||||
"tags": ["envelope"]
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const fillMessagePuhserDemo = () => {
|
||||
Object.assign(webhookSettings.value, messagePusherDemo)
|
||||
const presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({
|
||||
label: preset.name,
|
||||
key: index,
|
||||
}))
|
||||
|
||||
const handlePresetSelect = (key: number) => {
|
||||
const preset = presets[key]
|
||||
if (!preset) {
|
||||
message.error('Invalid preset')
|
||||
return
|
||||
}
|
||||
Object.assign(webhookSettings.value, preset.settings)
|
||||
message.success(t('fillInDemoTip'))
|
||||
window.open(preset.doc, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
|
||||
@@ -128,12 +183,11 @@ onMounted(async () => {
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
|
||||
{{ t('messagePusherDoc') }}
|
||||
</n-button>
|
||||
<n-button @click="fillMessagePuhserDemo" secondary>
|
||||
{{ t('messagePusherDemo') }}
|
||||
</n-button>
|
||||
<n-dropdown :options="presetDropdownOptions" @select="handlePresetSelect">
|
||||
<n-button secondary>
|
||||
{{ t('presets') }}
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
|
||||
{{ t('test') }}
|
||||
</n-button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type UserOauth2Settings = {
|
||||
name: string;
|
||||
icon?: string; // SVG icon string for the provider
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
authorizationURL: string;
|
||||
@@ -9,6 +10,9 @@ export type UserOauth2Settings = {
|
||||
redirectURL: string;
|
||||
logoutURL?: string;
|
||||
userEmailKey: string;
|
||||
enableEmailFormat?: boolean; // Enable email format transformation
|
||||
userEmailFormat?: string; // Regex pattern to match email
|
||||
userEmailReplace?: string; // Replacement template using $1, $2, etc.
|
||||
scope: string;
|
||||
enableMailAllowList?: boolean | undefined;
|
||||
mailAllowList?: string[] | undefined;
|
||||
|
||||
@@ -34,9 +34,11 @@ export const useGlobalState = createGlobalState(
|
||||
cfTurnstileSiteKey: '',
|
||||
enableWebhook: false,
|
||||
isS3Enabled: false,
|
||||
enableSendMail: false,
|
||||
showGithub: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
statusUrl: '',
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -83,7 +85,7 @@ export const useGlobalState = createGlobalState(
|
||||
fetched: false,
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
/** @type {{ clientID: string, name: string }[]} */
|
||||
/** @type {{ clientID: string, name: string, icon?: string }[]} */
|
||||
oauth2ClientIDs: [],
|
||||
});
|
||||
const userSettings = ref({
|
||||
|
||||
265
frontend/src/utils/__tests__/mail-actions.test.js
Normal file
265
frontend/src/utils/__tests__/mail-actions.test.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildReplyModel, buildForwardModel } from '../mail-actions'
|
||||
|
||||
describe('buildReplyModel', () => {
|
||||
it('uses HTML content in blockquote when message is present', () => {
|
||||
const mail = {
|
||||
source: 'Alice <alice@example.com>',
|
||||
originalSource: 'alice@example.com',
|
||||
subject: 'Hello',
|
||||
message: '<p>HTML body</p>',
|
||||
text: 'Plain body',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe(
|
||||
'<p><br></p><blockquote><p>HTML body</p></blockquote><p><br></p>'
|
||||
)
|
||||
expect(result.contentType).toBe('html')
|
||||
})
|
||||
|
||||
it('falls back to plain text when message is empty string', () => {
|
||||
const mail = {
|
||||
source: 'bob@example.com',
|
||||
originalSource: 'bob@example.com',
|
||||
subject: 'Hi',
|
||||
message: '',
|
||||
text: 'Plain text fallback',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe(
|
||||
'<p><br></p><blockquote>Plain text fallback</blockquote><p><br></p>'
|
||||
)
|
||||
expect(result.contentType).toBe('rich')
|
||||
})
|
||||
|
||||
it('falls back to plain text when message is null', () => {
|
||||
const mail = {
|
||||
source: 'carol@example.com',
|
||||
originalSource: 'carol@example.com',
|
||||
subject: 'Test',
|
||||
message: null,
|
||||
text: 'Fallback text',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe(
|
||||
'<p><br></p><blockquote>Fallback text</blockquote><p><br></p>'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty content when both message and text are empty', () => {
|
||||
const mail = {
|
||||
source: 'dave@example.com',
|
||||
originalSource: 'dave@example.com',
|
||||
subject: 'Empty',
|
||||
message: '',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty content when both message and text are null', () => {
|
||||
const mail = {
|
||||
source: 'eve@example.com',
|
||||
originalSource: 'eve@example.com',
|
||||
subject: 'Null',
|
||||
message: null,
|
||||
text: null,
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
it('parses "Name <email>" format for sender', () => {
|
||||
const mail = {
|
||||
source: 'Alice Smith <alice@example.com>',
|
||||
originalSource: 'alice@example.com',
|
||||
subject: 'Test',
|
||||
message: '<p>body</p>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.toName).toBe('Alice Smith')
|
||||
expect(result.toMail).toBe('alice@example.com')
|
||||
})
|
||||
|
||||
it('uses originalSource as toMail when source is plain email', () => {
|
||||
const mail = {
|
||||
source: 'plain@example.com',
|
||||
originalSource: 'plain@example.com',
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'body',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.toName).toBe('')
|
||||
expect(result.toMail).toBe('plain@example.com')
|
||||
})
|
||||
|
||||
it('defaults toMail to empty string when originalSource is null', () => {
|
||||
const mail = {
|
||||
source: 'plain@example.com',
|
||||
originalSource: null,
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'body',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.toMail).toBe('')
|
||||
})
|
||||
|
||||
it('formats subject with reply label', () => {
|
||||
const mail = {
|
||||
source: 'test@example.com',
|
||||
originalSource: 'test@example.com',
|
||||
subject: 'Original Subject',
|
||||
message: '',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.subject).toBe('Reply: Original Subject')
|
||||
})
|
||||
|
||||
it('uses html contentType for HTML email reply', () => {
|
||||
const mail = {
|
||||
source: 'test@example.com',
|
||||
originalSource: 'test@example.com',
|
||||
subject: 'Test',
|
||||
message: '<p>html</p>',
|
||||
text: 'plain',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.contentType).toBe('html')
|
||||
})
|
||||
|
||||
it('uses rich contentType for plain text email reply', () => {
|
||||
const mail = {
|
||||
source: 'test@example.com',
|
||||
originalSource: 'test@example.com',
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'plain',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.contentType).toBe('rich')
|
||||
})
|
||||
|
||||
it('strips script tags from HTML reply content (XSS)', () => {
|
||||
const mail = {
|
||||
source: 'attacker@example.com',
|
||||
originalSource: 'attacker@example.com',
|
||||
subject: 'XSS',
|
||||
message: '<p>Hello</p><script>alert("xss")</script><p>World</p>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).not.toContain('<script>')
|
||||
expect(result.content).toContain('<p>Hello</p>')
|
||||
expect(result.content).toContain('<p>World</p>')
|
||||
})
|
||||
|
||||
it('strips event handlers from HTML reply content (XSS)', () => {
|
||||
const mail = {
|
||||
source: 'attacker@example.com',
|
||||
originalSource: 'attacker@example.com',
|
||||
subject: 'XSS',
|
||||
message: '<img src=x onerror="alert(1)"><p>Text</p>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).not.toContain('onerror')
|
||||
expect(result.content).toContain('<p>Text</p>')
|
||||
})
|
||||
|
||||
it('escapes HTML chars in plain text reply content', () => {
|
||||
const mail = {
|
||||
source: 'user@example.com',
|
||||
originalSource: 'user@example.com',
|
||||
subject: 'Test',
|
||||
message: '',
|
||||
text: 'a < b & c > d',
|
||||
}
|
||||
const result = buildReplyModel(mail, 'Reply')
|
||||
expect(result.content).toContain('a < b & c > d')
|
||||
expect(result.content).not.toContain('a < b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardModel', () => {
|
||||
it('uses html contentType when message is present', () => {
|
||||
const mail = {
|
||||
subject: 'FW Test',
|
||||
message: '<p>HTML content</p>',
|
||||
text: 'Plain content',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('html')
|
||||
expect(result.content).toBe('<p>HTML content</p>')
|
||||
})
|
||||
|
||||
it('uses text contentType when message is empty', () => {
|
||||
const mail = {
|
||||
subject: 'FW Test',
|
||||
message: '',
|
||||
text: 'Plain text only',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('text')
|
||||
expect(result.content).toBe('Plain text only')
|
||||
})
|
||||
|
||||
it('uses text contentType when message is null', () => {
|
||||
const mail = {
|
||||
subject: 'FW Test',
|
||||
message: null,
|
||||
text: 'Fallback text',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('text')
|
||||
expect(result.content).toBe('Fallback text')
|
||||
})
|
||||
|
||||
it('formats subject with forward label', () => {
|
||||
const mail = {
|
||||
subject: 'Original',
|
||||
message: '',
|
||||
text: '',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.subject).toBe('Forward: Original')
|
||||
})
|
||||
|
||||
it('strips script tags from HTML forward content (XSS)', () => {
|
||||
const mail = {
|
||||
subject: 'XSS Test',
|
||||
message: '<div>Safe</div><script>alert("xss")</script>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.content).not.toContain('<script>')
|
||||
expect(result.content).toContain('<div>Safe</div>')
|
||||
})
|
||||
|
||||
it('strips event handlers from HTML forward content (XSS)', () => {
|
||||
const mail = {
|
||||
subject: 'XSS Test',
|
||||
message: '<img src=x onerror="alert(1)"><b>Bold</b>',
|
||||
text: '',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.content).not.toContain('onerror')
|
||||
expect(result.content).toContain('<b>Bold</b>')
|
||||
})
|
||||
|
||||
it('escapes special chars in plain text forward content', () => {
|
||||
const mail = {
|
||||
subject: 'FW Text',
|
||||
message: '',
|
||||
text: 'a < b & c > d',
|
||||
}
|
||||
const result = buildForwardModel(mail, 'Forward')
|
||||
expect(result.contentType).toBe('text')
|
||||
expect(result.content).toBe('a < b & c > d')
|
||||
})
|
||||
})
|
||||
68
frontend/src/utils/mail-actions.js
Normal file
68
frontend/src/utils/mail-actions.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* HTML-escape special characters for plain text content.
|
||||
*/
|
||||
function escapeHtml(str) {
|
||||
const text = String(str ?? '');
|
||||
return text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize mail content: HTML-escape plain text, whitelist-sanitize HTML.
|
||||
*/
|
||||
function sanitizeContent(mail) {
|
||||
if (mail.message) {
|
||||
return DOMPurify.sanitize(mail.message);
|
||||
}
|
||||
if (mail.text) {
|
||||
return escapeHtml(mail.text);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the send-mail model for replying to an email.
|
||||
* @param {Object} mail - The mail object (curMail)
|
||||
* @param {string} replyLabel - Translated "Reply" label
|
||||
* @returns {Object} Fields to assign onto sendMailModel
|
||||
*/
|
||||
export function buildReplyModel(mail, replyLabel) {
|
||||
const emailRegex = /(.+?) <(.+?)>/;
|
||||
let toMail = mail.originalSource || '';
|
||||
let toName = "";
|
||||
const match = emailRegex.exec(mail.source);
|
||||
if (match) {
|
||||
toName = match[1];
|
||||
toMail = match[2];
|
||||
}
|
||||
const safeContent = sanitizeContent(mail);
|
||||
return {
|
||||
toName,
|
||||
toMail,
|
||||
subject: `${replyLabel}: ${mail.subject}`,
|
||||
contentType: mail.message ? 'html' : 'rich',
|
||||
content: safeContent
|
||||
? `<p><br></p><blockquote>${safeContent}</blockquote><p><br></p>`
|
||||
: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the send-mail model for forwarding an email.
|
||||
* @param {Object} mail - The mail object (curMail)
|
||||
* @param {string} forwardLabel - Translated "Forward" label
|
||||
* @returns {Object} Fields to assign onto sendMailModel
|
||||
*/
|
||||
export function buildForwardModel(mail, forwardLabel) {
|
||||
return {
|
||||
subject: `${forwardLabel}: ${mail.subject}`,
|
||||
contentType: mail.message ? 'html' : 'text',
|
||||
content: sanitizeContent(mail),
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import {
|
||||
DarkModeFilled, LightModeFilled, MenuFilled,
|
||||
AdminPanelSettingsFilled
|
||||
AdminPanelSettingsFilled, MonitorHeartFilled
|
||||
} from '@vicons/material'
|
||||
import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
||||
|
||||
@@ -59,6 +59,7 @@ const { locale, t } = useI18n({
|
||||
home: 'Home',
|
||||
menu: 'Menu',
|
||||
user: 'User',
|
||||
status: 'Status',
|
||||
ok: 'OK',
|
||||
},
|
||||
zh: {
|
||||
@@ -70,6 +71,7 @@ const { locale, t } = useI18n({
|
||||
home: '主页',
|
||||
menu: '菜单',
|
||||
user: '用户',
|
||||
status: '状态',
|
||||
ok: '确定',
|
||||
}
|
||||
}
|
||||
@@ -179,6 +181,25 @@ const menuOptions = computed(() => [
|
||||
),
|
||||
key: "lang"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
size: "small",
|
||||
style: "width: 100%",
|
||||
tag: "a",
|
||||
target: "_blank",
|
||||
href: openSettings.value?.statusUrl,
|
||||
},
|
||||
{
|
||||
default: () => t('status'),
|
||||
icon: () => h(NIcon, { component: MonitorHeartFilled })
|
||||
}
|
||||
),
|
||||
show: !!openSettings.value?.statusUrl,
|
||||
key: "status"
|
||||
},
|
||||
{
|
||||
label: () => h(
|
||||
NButton,
|
||||
|
||||
@@ -156,15 +156,15 @@ onMounted(() => {
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:deleteMail="deleteSenboxMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendmail" :tab="t('sendmail')">
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
||||
|
||||
@@ -323,7 +323,21 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: t('source_meta'),
|
||||
key: "source_meta"
|
||||
key: "source_meta",
|
||||
render(row) {
|
||||
const val = row.source_meta;
|
||||
if (!val) return '';
|
||||
const ipv4Regex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
||||
const ipv6Regex = /^[0-9a-fA-F:]+$/;
|
||||
if (ipv4Regex.test(val) || (val.includes(':') && ipv6Regex.test(val) && !val.startsWith('tg:'))) {
|
||||
return h('a', {
|
||||
href: `https://ip.im/${val}`,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}, val);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('mail_count'),
|
||||
|
||||
@@ -25,7 +25,13 @@ const { t } = useI18n({
|
||||
mailAllowList: 'Mail Address Allow List',
|
||||
addOauth2: 'Add Oauth2',
|
||||
name: 'Name',
|
||||
icon: 'Icon (SVG, please ensure trusted source)',
|
||||
iconPreview: 'Preview',
|
||||
oauth2Type: 'Oauth2 Type',
|
||||
enableEmailFormat: 'Enable Email Format',
|
||||
userEmailFormat: 'Email Regex Pattern',
|
||||
userEmailReplace: 'Replace Template',
|
||||
userEmailFormatTip: 'Use regex to transform email. Example: ^(.+)@old\\.com$ with $1@new.com',
|
||||
tip: 'Third-party login will automatically use the user\'s email to register an account (the same email will be regarded as the same account), this account is the same as the registered account, and you can also set the password through the forget password',
|
||||
},
|
||||
zh: {
|
||||
@@ -38,12 +44,24 @@ const { t } = useI18n({
|
||||
mailAllowList: '邮件地址白名单',
|
||||
addOauth2: '添加 Oauth2',
|
||||
name: '名称',
|
||||
icon: '图标 (SVG, 请确保来源可信)',
|
||||
iconPreview: '预览',
|
||||
oauth2Type: 'Oauth2 类型',
|
||||
enableEmailFormat: '启用邮箱格式转换',
|
||||
userEmailFormat: '邮箱正则表达式',
|
||||
userEmailReplace: '替换模板',
|
||||
userEmailFormatTip: '使用正则转换邮箱。示例: ^(.+)@old\\.com$ 配合 $1@new.com',
|
||||
tip: '第三方登录会自动使用用户邮箱注册账号(邮箱相同将视为同一账号), 此账号和注册的账号相同, 也可以通过忘记密码设置密码',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const OAUTH2_ICONS: Record<string, string> = {
|
||||
github: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>',
|
||||
linuxdo: '<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" width="1em" height="1em"><g><path d="m7.44,0s.09,0,.13,0c.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0q.12,0,.25,0t.26.08c.15.03.29.06.44.08,1.97.38,3.78,1.47,4.95,3.11.04.06.09.12.13.18.67.96,1.15,2.11,1.3,3.28q0,.19.09.26c0,.15,0,.29,0,.44,0,.04,0,.09,0,.13,0,.09,0,.19,0,.28,0,.14,0,.29,0,.43,0,.09,0,.18,0,.27,0,.08,0,.17,0,.25q0,.19-.08.26c-.03.15-.06.29-.08.44-.38,1.97-1.47,3.78-3.11,4.95-.06.04-.12.09-.18.13-.96.67-2.11,1.15-3.28,1.3q-.19,0-.26.09c-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25,0q-.19,0-.26-.08c-.15-.03-.29-.06-.44-.08-1.97-.38-3.78-1.47-4.95-3.11q-.07-.09-.13-.18c-.67-.96-1.15-2.11-1.3-3.28q0-.19-.09-.26c0-.15,0-.29,0-.44,0-.04,0-.09,0-.13,0-.09,0-.19,0-.28,0-.14,0-.29,0-.43,0-.09,0-.18,0-.27,0-.08,0-.17,0-.25q0-.19.08-.26c.03-.15.06-.29.08-.44.38-1.97,1.47-3.78,3.11-4.95.06-.04.12-.09.18-.13C4.42.73,5.57.26,6.74.1,7,.07,7.15,0,7.44,0Z" fill="#EFEFEF"/><path d="m1.27,11.33h13.45c-.94,1.89-2.51,3.21-4.51,3.88-1.99.59-3.96.37-5.8-.57-1.25-.7-2.67-1.9-3.14-3.3Z" fill="#FEB005"/><path d="m12.54,1.99c.87.7,1.82,1.59,2.18,2.68H1.27c.87-1.74,2.33-3.13,4.2-3.78,2.44-.79,5-.47,7.07,1.1Z" fill="#1D1D1F"/></g></svg>',
|
||||
authentik: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12zM11 7v6h2V7h-2zm0 8v2h2v-2h-2z"/></svg>',
|
||||
};
|
||||
|
||||
const mailAllowOptions = constant.COMMOM_MAIL.map((item) => {
|
||||
return { label: item, value: item }
|
||||
})
|
||||
@@ -75,80 +93,59 @@ const save = async () => {
|
||||
}
|
||||
|
||||
const addNewOauth2 = () => {
|
||||
const authorizationURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/authorize'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/authorize/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://github.com/login/oauth/access_token'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/token/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const accessTokenFormat = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'json'
|
||||
case 'authentik':
|
||||
return 'urlencoded'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userInfoURL = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'https://api.github.com/user'
|
||||
case 'authentik':
|
||||
return 'https://youdomain/application/o/userinfo/'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const userEmailKey = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'email'
|
||||
case 'authentik':
|
||||
return 'email'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
const scope = () => {
|
||||
switch (newOauth2Type.value) {
|
||||
case 'github':
|
||||
return 'user:email'
|
||||
case 'authentik':
|
||||
return 'email openid'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
const templates: Record<string, Partial<UserOauth2Settings>> = {
|
||||
github: {
|
||||
authorizationURL: 'https://github.com/login/oauth/authorize',
|
||||
accessTokenURL: 'https://github.com/login/oauth/access_token',
|
||||
accessTokenFormat: 'json',
|
||||
userInfoURL: 'https://api.github.com/user',
|
||||
userEmailKey: 'email',
|
||||
scope: 'user:email',
|
||||
icon: OAUTH2_ICONS.github,
|
||||
},
|
||||
linuxdo: {
|
||||
authorizationURL: 'https://connect.linux.do/oauth2/authorize',
|
||||
accessTokenURL: 'https://connect.linux.do/oauth2/token',
|
||||
accessTokenFormat: 'urlencoded',
|
||||
userInfoURL: 'https://connect.linux.do/api/user',
|
||||
userEmailKey: 'id',
|
||||
scope: 'user',
|
||||
enableEmailFormat: true,
|
||||
userEmailFormat: '^(.+)$',
|
||||
userEmailReplace: 'linux_do_$1@oauth.linux.do',
|
||||
icon: OAUTH2_ICONS.linuxdo,
|
||||
},
|
||||
authentik: {
|
||||
authorizationURL: 'https://youdomain/application/o/authorize/',
|
||||
accessTokenURL: 'https://youdomain/application/o/token/',
|
||||
accessTokenFormat: 'urlencoded',
|
||||
userInfoURL: 'https://youdomain/application/o/userinfo/',
|
||||
userEmailKey: 'email',
|
||||
scope: 'email openid',
|
||||
icon: OAUTH2_ICONS.authentik,
|
||||
},
|
||||
custom: {},
|
||||
}
|
||||
const template = templates[newOauth2Type.value] || {}
|
||||
userOauth2Settings.value.push({
|
||||
name: newOauth2Name.value,
|
||||
icon: '',
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
authorizationURL: authorizationURL(),
|
||||
accessTokenURL: accessTokenURL(),
|
||||
accessTokenFormat: accessTokenFormat(),
|
||||
userInfoURL: userInfoURL(),
|
||||
userEmailKey: userEmailKey(),
|
||||
authorizationURL: '',
|
||||
accessTokenURL: '',
|
||||
accessTokenFormat: '',
|
||||
userInfoURL: '',
|
||||
userEmailKey: '',
|
||||
redirectURL: `${window.location.origin}/user/oauth2/callback`,
|
||||
logoutURL: '',
|
||||
scope: scope(),
|
||||
scope: '',
|
||||
enableEmailFormat: false,
|
||||
userEmailFormat: '',
|
||||
userEmailReplace: '',
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: constant.COMMOM_MAIL
|
||||
mailAllowList: constant.COMMOM_MAIL,
|
||||
...template,
|
||||
} as UserOauth2Settings)
|
||||
newOauth2Name.value = ''
|
||||
showAddOauth2.value = false
|
||||
@@ -174,6 +171,7 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('oauth2Type')" required>
|
||||
<n-radio-group v-model:value="newOauth2Type">
|
||||
<n-radio-button value="github" label="Github" />
|
||||
<n-radio-button value="linuxdo" label="Linux Do" />
|
||||
<n-radio-button value="authentik" label="Authentik" />
|
||||
<n-radio-button value="custom" label="Custom" />
|
||||
</n-radio-group>
|
||||
@@ -214,11 +212,18 @@ onMounted(async () => {
|
||||
<n-form-item-row :label="t('name')" required>
|
||||
<n-input v-model:value="item.name" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('icon')">
|
||||
<n-input v-model:value="item.icon" type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }" style="width: 100%;" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="item.icon" :label="t('iconPreview')">
|
||||
<span class="oauth2-icon-preview" v-html="item.icon"></span>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client ID" required>
|
||||
<n-input v-model:value="item.clientID" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Client Secret" required>
|
||||
<n-input v-model:value="item.clientSecret" type="password" show-password-on="click" />
|
||||
<n-input v-model:value="item.clientSecret" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Authorization URL" required>
|
||||
<n-input v-model:value="item.authorizationURL" />
|
||||
@@ -235,6 +240,27 @@ onMounted(async () => {
|
||||
<n-form-item-row label="User Email Key (Support JSONPATH like $[0].email)" required>
|
||||
<n-input v-model:value="item.userEmailKey" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('enableEmailFormat')">
|
||||
<n-checkbox v-model:checked="item.enableEmailFormat">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="item.enableEmailFormat" :label="t('userEmailFormat')">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-input v-model:value="item.userEmailFormat" :placeholder="'^(.+)@old\\.com$'" />
|
||||
</template>
|
||||
{{ t('userEmailFormatTip') }}
|
||||
</n-tooltip>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="item.enableEmailFormat" :label="t('userEmailReplace')">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-input v-model:value="item.userEmailReplace" placeholder="$1@new.com" />
|
||||
</template>
|
||||
{{ t('userEmailFormatTip') }}
|
||||
</n-tooltip>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row label="Redirect URL" required>
|
||||
<n-input v-model:value="item.redirectURL" />
|
||||
</n-form-item-row>
|
||||
@@ -271,4 +297,20 @@ onMounted(async () => {
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.oauth2-icon-preview {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.oauth2-icon-preview :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,8 @@ 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 @)',
|
||||
enableEmailCheckRegex: 'Enable Email Check Regex',
|
||||
},
|
||||
zh: {
|
||||
save: '保存',
|
||||
@@ -33,6 +35,8 @@ const { t } = useI18n({
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
mailAllowList: '邮件地址白名单',
|
||||
maxAddressCount: '可绑定最大邮箱地址数量',
|
||||
emailCheckRegex: '邮箱正则校验 (例如 ^[^.]+@.+$ 禁止@前面有.)',
|
||||
enableEmailCheckRegex: '启用邮箱正则校验',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -53,6 +57,8 @@ const userSettings = ref({
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: commonMail,
|
||||
maxAddressCount: 5,
|
||||
enableEmailCheckRegex: false,
|
||||
emailCheckRegex: "",
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -125,6 +131,16 @@ onMounted(async () => {
|
||||
:placeholder="t('maxAddressCount')" />
|
||||
</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%;">
|
||||
{{ t('enable') }}
|
||||
</n-checkbox>
|
||||
<n-input v-model:value="userSettings.emailCheckRegex"
|
||||
v-if="userSettings.enableEmailCheckRegex"
|
||||
style="width: 80%;" :placeholder="t('emailCheckRegex')" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ const send = async () => {
|
||||
|
||||
const requestAccess = async () => {
|
||||
try {
|
||||
await api.fetch(`/api/requset_send_mail_access`,
|
||||
await api.fetch(`/api/request_send_mail_access`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
|
||||
@@ -233,6 +233,9 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
<n-button @click="oauth2Login(item.clientID)" v-for="item in userOpenSettings.oauth2ClientIDs"
|
||||
:key="item.clientID" block secondary strong>
|
||||
<template #icon v-if="item.icon">
|
||||
<span class="oauth2-icon" v-html="item.icon"></span>
|
||||
</template>
|
||||
{{ t('loginWith', { provider: item.name }) }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
@@ -305,4 +308,17 @@ onMounted(async () => {
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.oauth2-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.oauth2-icon :deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.59.2"
|
||||
"wrangler": "^4.70.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
proxy_url=https://temp-email-api.xxx.xxx
|
||||
port=8025
|
||||
imap_port=11143
|
||||
# smtp_tls_cert=/path/to/cert.pem
|
||||
# smtp_tls_key=/path/to/key.pem
|
||||
# imap_tls_cert=/path/to/cert.pem
|
||||
# imap_tls_key=/path/to/key.pem
|
||||
# imap_cache_size=500
|
||||
# imap_http_timeout=30.0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
@@ -14,9 +15,28 @@ class Settings(BaseSettings):
|
||||
port: int = 8025
|
||||
imap_port: int = 11143
|
||||
basic_password: str = ""
|
||||
smtp_tls_cert: str = ""
|
||||
smtp_tls_key: str = ""
|
||||
imap_tls_cert: str = ""
|
||||
imap_tls_key: str = ""
|
||||
imap_cache_size: int = 500
|
||||
imap_http_timeout: float = 30.0
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
@field_validator("imap_cache_size")
|
||||
@classmethod
|
||||
def cache_size_positive(cls, v):
|
||||
if v <= 0:
|
||||
raise ValueError("imap_cache_size must be > 0")
|
||||
return v
|
||||
|
||||
@field_validator("imap_http_timeout")
|
||||
@classmethod
|
||||
def timeout_positive(cls, v):
|
||||
if v <= 0:
|
||||
raise ValueError("imap_http_timeout must be > 0")
|
||||
return v
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
69
smtp_proxy_server/imap_http_client.py
Normal file
69
smtp_proxy_server/imap_http_client.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from twisted.internet import defer, threads
|
||||
|
||||
from config import settings
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
class BackendClient:
|
||||
"""Async HTTP client for IMAP backend communication.
|
||||
|
||||
All public methods return Deferred via deferToThread to avoid
|
||||
blocking the Twisted reactor with synchronous HTTP calls.
|
||||
"""
|
||||
|
||||
def __init__(self, password: str):
|
||||
self.password = password
|
||||
self._client = httpx.Client(
|
||||
base_url=settings.proxy_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {password}",
|
||||
"x-custom-auth": settings.basic_password,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=settings.imap_http_timeout,
|
||||
)
|
||||
|
||||
def _get_endpoint(self, mailbox_name: str) -> str:
|
||||
if mailbox_name == "INBOX":
|
||||
return "/api/mails"
|
||||
elif mailbox_name == "SENT":
|
||||
return "/api/sendbox"
|
||||
raise ValueError(f"Unknown mailbox: {mailbox_name}")
|
||||
|
||||
def _sync_get_message_count(self, mailbox_name: str) -> int:
|
||||
endpoint = self._get_endpoint(mailbox_name)
|
||||
res = self._client.get(f"{endpoint}?limit=1&offset=0")
|
||||
res.raise_for_status()
|
||||
return res.json()["count"]
|
||||
|
||||
def _sync_get_messages(
|
||||
self, mailbox_name: str, limit: int, offset: int
|
||||
) -> tuple[list[dict], int | None]:
|
||||
"""Fetch messages from backend.
|
||||
|
||||
Returns (results, count) where count is only valid when offset=0.
|
||||
"""
|
||||
endpoint = self._get_endpoint(mailbox_name)
|
||||
res = self._client.get(f"{endpoint}?limit={limit}&offset={offset}")
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
count = data.get("count") if offset == 0 else None
|
||||
return data["results"], count
|
||||
|
||||
def get_message_count(self, mailbox_name: str) -> defer.Deferred:
|
||||
return threads.deferToThread(self._sync_get_message_count, mailbox_name)
|
||||
|
||||
def get_messages(
|
||||
self, mailbox_name: str, limit: int, offset: int
|
||||
) -> defer.Deferred:
|
||||
return threads.deferToThread(
|
||||
self._sync_get_messages, mailbox_name, limit, offset
|
||||
)
|
||||
|
||||
def close(self):
|
||||
self._client.close()
|
||||
357
smtp_proxy_server/imap_mailbox.py
Normal file
357
smtp_proxy_server/imap_mailbox.py
Normal file
@@ -0,0 +1,357 @@
|
||||
import bisect
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from twisted.internet import defer
|
||||
from twisted.mail import imap4
|
||||
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
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
# Use process start time as UIDVALIDITY so clients resync after restart
|
||||
_UID_VALIDITY = int(time.time())
|
||||
|
||||
|
||||
class MessageCache:
|
||||
"""LRU cache for parsed email messages, keyed by backend id (=UID)."""
|
||||
|
||||
def __init__(self, max_size: int = 500):
|
||||
self._cache: OrderedDict[int, SimpleMessage] = OrderedDict()
|
||||
self._max_size = max_size
|
||||
|
||||
def get(self, uid: int):
|
||||
if uid in self._cache:
|
||||
self._cache.move_to_end(uid)
|
||||
return self._cache[uid]
|
||||
return None
|
||||
|
||||
def put(self, uid: int, message: SimpleMessage):
|
||||
if uid in self._cache:
|
||||
self._cache.move_to_end(uid)
|
||||
self._cache[uid] = message
|
||||
else:
|
||||
if len(self._cache) >= self._max_size:
|
||||
self._cache.popitem(last=False)
|
||||
self._cache[uid] = message
|
||||
|
||||
def __contains__(self, uid: int) -> bool:
|
||||
return uid in self._cache
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
@implementer(imap4.IMailboxInfo, imap4.IMailbox, imap4.ISearchableMailbox)
|
||||
class SimpleMailbox:
|
||||
|
||||
def __init__(self, name: str, client: BackendClient):
|
||||
self.name = name
|
||||
self._client = client
|
||||
self.listeners = []
|
||||
self.addListener = self.listeners.append
|
||||
self.removeListener = self.listeners.remove
|
||||
self._message_count = 0
|
||||
self._uid_index: list[int] = []
|
||||
self._flags: dict[int, set[str]] = {}
|
||||
self._cache = MessageCache(max_size=settings.imap_cache_size)
|
||||
self._uid_index_built = False
|
||||
|
||||
def getFlags(self):
|
||||
return [r"\Seen", r"\Answered", r"\Flagged", r"\Deleted", r"\Draft"]
|
||||
|
||||
def getUIDValidity(self):
|
||||
return _UID_VALIDITY
|
||||
|
||||
def getMessageCount(self):
|
||||
return self._message_count
|
||||
|
||||
def getRecentCount(self):
|
||||
return 0
|
||||
|
||||
def getUnseenCount(self):
|
||||
return 0
|
||||
|
||||
def isWriteable(self):
|
||||
return 1
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def getHierarchicalDelimiter(self):
|
||||
return "/"
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def requestStatus(self, names):
|
||||
if not self._uid_index_built:
|
||||
yield self._build_uid_index()
|
||||
else:
|
||||
count = yield self._refresh_count()
|
||||
if count != self._message_count:
|
||||
self._message_count = count
|
||||
yield self._build_uid_index()
|
||||
|
||||
r = {}
|
||||
if "MESSAGES" in names:
|
||||
r["MESSAGES"] = self._message_count
|
||||
if "RECENT" in names:
|
||||
r["RECENT"] = self.getRecentCount()
|
||||
if "UIDNEXT" in names:
|
||||
r["UIDNEXT"] = self.getUIDNext()
|
||||
if "UIDVALIDITY" in names:
|
||||
r["UIDVALIDITY"] = self.getUIDValidity()
|
||||
if "UNSEEN" in names:
|
||||
r["UNSEEN"] = self.getUnseenCount()
|
||||
return r
|
||||
|
||||
def _refresh_count(self) -> defer.Deferred:
|
||||
return self._client.get_message_count(self.name)
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def _build_uid_index(self):
|
||||
"""Build UID index by fetching all message IDs from backend."""
|
||||
count = yield self._client.get_message_count(self.name)
|
||||
self._message_count = count
|
||||
_logger.info("Building UID index for %s: count=%d", self.name, count)
|
||||
|
||||
if count == 0:
|
||||
self._uid_index = []
|
||||
self._uid_index_built = True
|
||||
return
|
||||
|
||||
uid_set = set()
|
||||
batch_size = 100
|
||||
offset = 0
|
||||
|
||||
while offset < count:
|
||||
limit = min(batch_size, count - offset)
|
||||
results, _ = yield self._client.get_messages(
|
||||
self.name, limit, offset
|
||||
)
|
||||
for item in results:
|
||||
item_id = item.get("id")
|
||||
if item_id is not None and item_id not in uid_set:
|
||||
uid_set.add(item_id)
|
||||
_logger.info(
|
||||
"UID index batch: offset=%d limit=%d got=%d total_uids=%d",
|
||||
offset, limit, len(results), len(uid_set),
|
||||
)
|
||||
offset += limit
|
||||
|
||||
self._uid_index = sorted(uid_set)
|
||||
self._uid_index_built = True
|
||||
_logger.info(
|
||||
"UID index built for %s: %d UIDs, range=%s..%s",
|
||||
self.name, len(self._uid_index),
|
||||
self._uid_index[0] if self._uid_index else "N/A",
|
||||
self._uid_index[-1] if self._uid_index else "N/A",
|
||||
)
|
||||
|
||||
def _seq_to_uid(self, seq: int) -> int | None:
|
||||
"""Convert 1-based sequence number to UID."""
|
||||
if 1 <= seq <= len(self._uid_index):
|
||||
return self._uid_index[seq - 1]
|
||||
return None
|
||||
|
||||
def _uid_to_seq(self, uid: int) -> int | None:
|
||||
"""Convert UID to 1-based sequence number."""
|
||||
idx = bisect.bisect_left(self._uid_index, uid)
|
||||
if idx < len(self._uid_index) and self._uid_index[idx] == uid:
|
||||
return idx + 1
|
||||
return None
|
||||
|
||||
def _resolve_message_set(self, messages, uid: bool) -> list[int]:
|
||||
"""Resolve an IMAP MessageSet to a list of UIDs."""
|
||||
result_uids = []
|
||||
if not self._uid_index:
|
||||
return result_uids
|
||||
|
||||
max_uid = self._uid_index[-1]
|
||||
max_seq = len(self._uid_index)
|
||||
|
||||
_logger.info(
|
||||
"Resolving message_set: uid=%s ranges=%s max_uid=%d max_seq=%d",
|
||||
uid, list(messages.ranges), max_uid, max_seq,
|
||||
)
|
||||
|
||||
for start, end in messages.ranges:
|
||||
if uid:
|
||||
actual_end = end if end is not None else max_uid
|
||||
for u in self._uid_index:
|
||||
if start <= u <= actual_end:
|
||||
result_uids.append(u)
|
||||
else:
|
||||
actual_end = end if end is not None else max_seq
|
||||
actual_start = max(start, 1)
|
||||
actual_end = min(actual_end, max_seq)
|
||||
for seq in range(actual_start, actual_end + 1):
|
||||
u = self._seq_to_uid(seq)
|
||||
if u is not None:
|
||||
result_uids.append(u)
|
||||
|
||||
return result_uids
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def _fetch_and_cache_messages(self, uids: list[int]):
|
||||
"""Fetch uncached messages from backend in batches."""
|
||||
uncached = [u for u in uids if u not in self._cache]
|
||||
if not uncached:
|
||||
return
|
||||
|
||||
uncached_set = set(uncached)
|
||||
id_to_data = {}
|
||||
batch_size = 50
|
||||
total = self._message_count
|
||||
|
||||
_logger.info(
|
||||
"Fetching %d uncached messages (total=%d) for %s",
|
||||
len(uncached), total, self.name,
|
||||
)
|
||||
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
fetched_ids = set()
|
||||
offset = 0
|
||||
|
||||
while offset < total and len(fetched_ids) < len(uncached):
|
||||
limit = min(batch_size, total - offset)
|
||||
results, _ = yield self._client.get_messages(
|
||||
self.name, limit, offset
|
||||
)
|
||||
for item in results:
|
||||
item_id = item.get("id")
|
||||
if item_id in uncached_set and item_id not in fetched_ids:
|
||||
id_to_data[item_id] = item
|
||||
fetched_ids.add(item_id)
|
||||
|
||||
if len(fetched_ids) >= len(uncached):
|
||||
break
|
||||
offset += limit
|
||||
|
||||
_logger.info(
|
||||
"Fetched %d/%d messages for %s",
|
||||
len(id_to_data), len(uncached), self.name,
|
||||
)
|
||||
|
||||
for uid_val in uncached:
|
||||
if uid_val in id_to_data:
|
||||
item = id_to_data[uid_val]
|
||||
try:
|
||||
if self.name == "INBOX":
|
||||
raw = item.get("raw", "")
|
||||
email_model = parse_email(raw)
|
||||
elif self.name == "SENT":
|
||||
email_model, raw = generate_email_model(item)
|
||||
else:
|
||||
continue
|
||||
|
||||
if uid_val not in self._flags:
|
||||
self._flags[uid_val] = {r"\Seen"}
|
||||
flags = self._flags[uid_val]
|
||||
msg = SimpleMessage(
|
||||
uid_val, email_model, flags=flags, raw=raw
|
||||
)
|
||||
self._cache.put(uid_val, msg)
|
||||
except Exception as e:
|
||||
_logger.error(f"Failed to parse message uid={uid_val}: {e}")
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def fetch(self, messages, uid):
|
||||
if not self._uid_index_built:
|
||||
yield self._build_uid_index()
|
||||
else:
|
||||
count = yield self._refresh_count()
|
||||
if count != self._message_count:
|
||||
self._message_count = count
|
||||
yield self._build_uid_index()
|
||||
|
||||
target_uids = self._resolve_message_set(messages, uid)
|
||||
_logger.info(
|
||||
"FETCH: uid=%s target_uids=%d message_set=%s",
|
||||
uid, len(target_uids),
|
||||
target_uids[:5] if len(target_uids) > 5 else target_uids,
|
||||
)
|
||||
if not target_uids:
|
||||
return []
|
||||
|
||||
yield self._fetch_and_cache_messages(target_uids)
|
||||
|
||||
result = []
|
||||
for u in target_uids:
|
||||
cached = self._cache.get(u)
|
||||
if cached is not None:
|
||||
flags = self._flags.get(u, set())
|
||||
cached._flags = flags
|
||||
seq = self._uid_to_seq(u)
|
||||
if seq is not None:
|
||||
result.append((seq, cached))
|
||||
|
||||
return result
|
||||
|
||||
def getUID(self, message):
|
||||
return message
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def store(self, messages, flags, mode, uid):
|
||||
if not self._uid_index_built:
|
||||
yield self._build_uid_index()
|
||||
if not self._uid_index:
|
||||
return {}
|
||||
|
||||
target_uids = self._resolve_message_set(messages, uid)
|
||||
result = {}
|
||||
|
||||
for u in target_uids:
|
||||
current_flags = self._flags.get(u, set())
|
||||
|
||||
if mode == 1: # +FLAGS
|
||||
current_flags = current_flags | set(flags)
|
||||
elif mode == -1: # -FLAGS
|
||||
current_flags = current_flags - set(flags)
|
||||
elif mode == 0: # FLAGS (replace)
|
||||
current_flags = set(flags)
|
||||
|
||||
self._flags[u] = current_flags
|
||||
seq = self._uid_to_seq(u)
|
||||
if seq is not None:
|
||||
result[seq] = current_flags
|
||||
|
||||
return result
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def search(self, query, uid):
|
||||
if not self._uid_index_built:
|
||||
yield self._build_uid_index()
|
||||
|
||||
results = []
|
||||
|
||||
for term in query:
|
||||
if isinstance(term, str) and term.upper() == "ALL":
|
||||
if uid:
|
||||
results = list(self._uid_index)
|
||||
else:
|
||||
results = list(range(1, len(self._uid_index) + 1))
|
||||
break
|
||||
|
||||
if not results:
|
||||
if uid:
|
||||
results = list(self._uid_index)
|
||||
else:
|
||||
results = list(range(1, len(self._uid_index) + 1))
|
||||
|
||||
return results
|
||||
|
||||
def getUIDNext(self):
|
||||
if self._uid_index:
|
||||
return self._uid_index[-1] + 1
|
||||
return 1
|
||||
|
||||
def expunge(self):
|
||||
return defer.succeed([])
|
||||
71
smtp_proxy_server/imap_message.py
Normal file
71
smtp_proxy_server/imap_message.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from io import BytesIO
|
||||
|
||||
from twisted.mail import imap4
|
||||
from zope.interface import implementer
|
||||
|
||||
from models import EmailModel
|
||||
|
||||
|
||||
@implementer(imap4.IMessage, imap4.IMessageFile)
|
||||
class SimpleMessage:
|
||||
|
||||
def __init__(self, uid: int, email_model: EmailModel,
|
||||
flags: set[str] = None, raw: 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
|
||||
|
||||
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):
|
||||
names_lower.add(n.decode("ascii", errors="replace").lower())
|
||||
else:
|
||||
names_lower.add(n.lower())
|
||||
if not names_lower:
|
||||
return {k.lower(): v for k, v in self.email.headers.items()}
|
||||
if negate:
|
||||
return {
|
||||
k.lower(): v
|
||||
for k, v in self.email.headers.items()
|
||||
if k.lower() not in names_lower
|
||||
}
|
||||
return {
|
||||
k.lower(): v
|
||||
for k, v in self.email.headers.items()
|
||||
if k.lower() in names_lower
|
||||
}
|
||||
|
||||
def isMultipart(self):
|
||||
return len(self.subparts) > 0
|
||||
|
||||
def getSubPart(self, part):
|
||||
return SimpleMessage(self.uid, self.subparts[part], flags=self._flags)
|
||||
|
||||
def getBodyFile(self):
|
||||
return BytesIO(self.email.body.encode("utf-8"))
|
||||
|
||||
def getSize(self):
|
||||
if self._raw is not None:
|
||||
return len(self._raw.encode("utf-8"))
|
||||
return self.email.size
|
||||
|
||||
def getFlags(self):
|
||||
return list(self._flags)
|
||||
|
||||
def getInternalDate(self):
|
||||
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
|
||||
|
||||
# IMessageFile
|
||||
def open(self):
|
||||
"""Return complete raw MIME message for BODY[] requests."""
|
||||
if self._raw is not None:
|
||||
return BytesIO(self._raw.encode("utf-8"))
|
||||
return BytesIO(self.email.body.encode("utf-8"))
|
||||
@@ -1,292 +1,128 @@
|
||||
import json
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from io import BytesIO
|
||||
import httpx
|
||||
from twisted.mail import imap4
|
||||
from zope.interface import implementer
|
||||
from twisted.cred.portal import Portal, IRealm
|
||||
from twisted.internet import protocol, reactor, defer
|
||||
from twisted.internet import protocol, reactor, defer, ssl, threads
|
||||
from twisted.cred import error as cred_error
|
||||
from twisted.cred.checkers import ICredentialsChecker, IUsernamePassword
|
||||
|
||||
from config import settings
|
||||
from parse_email import generate_email_model, parse_email
|
||||
from models import EmailModel
|
||||
from imap_http_client import BackendClient
|
||||
from imap_mailbox import SimpleMailbox
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
@implementer(imap4.IMessage)
|
||||
class SimpleMessage:
|
||||
|
||||
def __init__(self, uid=None, email_model: EmailModel = None):
|
||||
self.uid = uid
|
||||
self.email = email_model
|
||||
self.subparts = self.email.subparts
|
||||
|
||||
def getUID(self):
|
||||
return self.uid
|
||||
|
||||
def getHeaders(self, negate, *names):
|
||||
self.got_headers = negate, names
|
||||
return {
|
||||
k.lower(): v
|
||||
for k, v in self.email.headers.items()
|
||||
}
|
||||
|
||||
def isMultipart(self):
|
||||
return len(self.subparts) > 0
|
||||
|
||||
def getSubPart(self, part):
|
||||
self.got_subpart = part
|
||||
return SimpleMessage(email_model=self.subparts[part])
|
||||
|
||||
def getBodyFile(self):
|
||||
return BytesIO(self.email.body.encode("utf-8"))
|
||||
|
||||
def getSize(self):
|
||||
return self.email.size
|
||||
|
||||
def getFlags(self):
|
||||
return ["\\Seen"]
|
||||
|
||||
def getInternalDate(self):
|
||||
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
|
||||
|
||||
|
||||
@implementer(imap4.IMailboxInfo, imap4.IMailbox)
|
||||
class SimpleMailbox:
|
||||
|
||||
def __init__(self, name, password):
|
||||
self.name = name
|
||||
self.password = password
|
||||
self.listeners = []
|
||||
self.addListener = self.listeners.append
|
||||
self.removeListener = self.listeners.remove
|
||||
self.message_count = 0
|
||||
self._update_message_count()
|
||||
|
||||
def _update_message_count(self):
|
||||
"""主动获取邮件总数"""
|
||||
try:
|
||||
if self.name == "INBOX":
|
||||
endpoint = "/api/mails"
|
||||
elif self.name == "SENT":
|
||||
endpoint = "/api/sendbox"
|
||||
else:
|
||||
return
|
||||
|
||||
res = httpx.get(
|
||||
f"{settings.proxy_url}{endpoint}?limit=1&offset=0",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.password}",
|
||||
"x-custom-auth": f"{settings.basic_password}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res.status_code == 200:
|
||||
self.message_count = res.json()["count"]
|
||||
# _logger.info(f"Updated {self.name} message count: {self.message_count}")
|
||||
except Exception as e:
|
||||
_logger.error(f"Failed to update message count for {self.name}: {e}")
|
||||
|
||||
def getFlags(self):
|
||||
return ["\\Seen"]
|
||||
|
||||
def getUIDValidity(self):
|
||||
return 0
|
||||
|
||||
def getMessageCount(self):
|
||||
# 每次请求时更新邮件总数
|
||||
self._update_message_count()
|
||||
return self.message_count
|
||||
|
||||
def getRecentCount(self):
|
||||
return 0
|
||||
|
||||
def getUnseenCount(self):
|
||||
return 0
|
||||
|
||||
def isWriteable(self):
|
||||
return 0
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def getHierarchicalDelimiter(self):
|
||||
return "/"
|
||||
|
||||
def requestStatus(self, names):
|
||||
# 在状态请求时也更新邮件总数
|
||||
self._update_message_count()
|
||||
r = {}
|
||||
if "MESSAGES" in names:
|
||||
r["MESSAGES"] = self.getMessageCount()
|
||||
if "RECENT" in names:
|
||||
r["RECENT"] = self.getRecentCount()
|
||||
if "UIDNEXT" in names:
|
||||
r["UIDNEXT"] = self.getMessageCount() + 1
|
||||
if "UIDVALIDITY" in names:
|
||||
r["UIDVALIDITY"] = self.getUIDValidity()
|
||||
if "UNSEEN" in names:
|
||||
r["UNSEEN"] = self.getUnseenCount()
|
||||
return defer.succeed(r)
|
||||
|
||||
def fetch(self, messages, uid):
|
||||
"""边查边返回邮件"""
|
||||
result = []
|
||||
for range_item in messages.ranges:
|
||||
start, end = range_item
|
||||
_logger.info(f"Fetching messages: {self.name}, range: {start}-{end}")
|
||||
|
||||
for email_data in self.fetchGenerator(start, end):
|
||||
result.append(email_data)
|
||||
|
||||
# 返回列表而不是生成器,以支持 IMAP SEARCH 等需要索引访问的操作
|
||||
return result
|
||||
|
||||
def fetchGenerator(self, start, end):
|
||||
"""通用的邮件获取生成器,边查边返回"""
|
||||
start = max(start, 1)
|
||||
|
||||
# 根据邮箱类型确定API端点
|
||||
if self.name == "INBOX":
|
||||
endpoint = "/api/mails"
|
||||
elif self.name == "SENT":
|
||||
endpoint = "/api/sendbox"
|
||||
else:
|
||||
return
|
||||
|
||||
# 首先获取服务端邮件总数
|
||||
count_res = httpx.get(
|
||||
f"{settings.proxy_url}{endpoint}?limit=1&offset=0",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.password}",
|
||||
"x-custom-auth": f"{settings.basic_password}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if count_res.status_code != 200:
|
||||
_logger.error(
|
||||
f"Failed to get {self.name} email count: "
|
||||
f"code=[{count_res.status_code}] text=[{count_res.text}]"
|
||||
)
|
||||
return
|
||||
|
||||
total_count = count_res.json()["count"]
|
||||
self.message_count = total_count
|
||||
|
||||
if total_count == 0 or start > total_count:
|
||||
return
|
||||
|
||||
# 分批处理,每次获取一小批就立即返回
|
||||
batch_size = 20
|
||||
current_start = start
|
||||
current_end = min(end or total_count, total_count)
|
||||
|
||||
while current_start <= current_end:
|
||||
batch_end = min(current_start + batch_size - 1, current_end)
|
||||
|
||||
# 计算这一批的参数
|
||||
limit = batch_end - current_start + 1
|
||||
server_offset = total_count - batch_end
|
||||
server_offset = max(0, server_offset)
|
||||
|
||||
_logger.info(
|
||||
f"Fetching batch: start={current_start}, end={batch_end}, "
|
||||
f"total_count={total_count}, limit={limit}, "
|
||||
f"server_offset={server_offset}"
|
||||
)
|
||||
|
||||
res = httpx.get(
|
||||
f"{settings.proxy_url}{endpoint}?limit={limit}&offset={server_offset}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.password}",
|
||||
"x-custom-auth": f"{settings.basic_password}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
if res.status_code != 200:
|
||||
_logger.error(
|
||||
f"Failed to fetch {self.name} emails: "
|
||||
f"code=[{res.status_code}] text=[{res.text}]"
|
||||
)
|
||||
break
|
||||
|
||||
emails = res.json()["results"]
|
||||
for i, item in enumerate(reversed(emails)):
|
||||
uid = total_count - server_offset - len(emails) + i + 1
|
||||
if current_start <= uid <= batch_end:
|
||||
if self.name == "INBOX":
|
||||
email_model = parse_email(item["raw"])
|
||||
elif self.name == "SENT":
|
||||
email_model = generate_email_model(item)
|
||||
|
||||
# 立即返回这封邮件
|
||||
yield (uid, SimpleMessage(uid, email_model))
|
||||
|
||||
current_start = batch_end + 1
|
||||
|
||||
def getUID(self, message):
|
||||
return message.uid
|
||||
|
||||
def store(self, messages, flags, mode, uid):
|
||||
# IMailboxIMAP.store
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Account(imap4.MemoryAccount):
|
||||
|
||||
def __init__(self, user, password):
|
||||
self.password = password
|
||||
super().__init__(user)
|
||||
|
||||
def isSubscribed(self, name):
|
||||
return name.upper() in ["INBOX", "SENT"]
|
||||
|
||||
def _emptyMailbox(self, name, id):
|
||||
_logger.info(f"New mailbox: {name}, {id}")
|
||||
if name == "INBOX":
|
||||
return SimpleMailbox(name, self.password)
|
||||
if name == "SENT":
|
||||
return SimpleMailbox(name, self.password)
|
||||
raise imap4.NoSuchMailbox(name.encode("utf-8"))
|
||||
|
||||
def select(self, name, rw=1):
|
||||
return imap4.MemoryAccount.select(self, name)
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
class SimpleIMAPServer(imap4.IMAP4Server):
|
||||
def __init__(self, factory):
|
||||
imap4.IMAP4Server.__init__(self)
|
||||
self.factory = factory
|
||||
def __init__(self, context_factory=None):
|
||||
chal = {
|
||||
b"LOGIN": imap4.LOGINCredentials,
|
||||
b"PLAIN": imap4.PLAINCredentials,
|
||||
}
|
||||
imap4.IMAP4Server.__init__(
|
||||
self, chal=chal, contextFactory=context_factory
|
||||
)
|
||||
|
||||
def lineReceived(self, line):
|
||||
# _logger.info(f"Received: {line}")
|
||||
super().lineReceived(line)
|
||||
_logger.debug("C: %s", line)
|
||||
return imap4.IMAP4Server.lineReceived(self, line)
|
||||
|
||||
def sendLine(self, line):
|
||||
# _logger.info(f"Sent: {line}")
|
||||
super().sendLine(line)
|
||||
_logger.debug("S: %s", line)
|
||||
return imap4.IMAP4Server.sendLine(self, line)
|
||||
|
||||
def connectionMade(self):
|
||||
"""Wrap transport to log raw data sent to client."""
|
||||
imap4.IMAP4Server.connectionMade(self)
|
||||
real_write_seq = self.transport.writeSequence
|
||||
def logging_write_seq(data):
|
||||
joined = b''.join(data)
|
||||
for line in joined.split(b'\r\n'):
|
||||
if line:
|
||||
_logger.debug("S-RAW: %s", line[:300])
|
||||
return real_write_seq(data)
|
||||
self.transport.writeSequence = logging_write_seq
|
||||
|
||||
def _cbSelectWork(self, mbox, cmdName, tag):
|
||||
"""Override to add UIDNEXT in SELECT response (RFC 3501)."""
|
||||
if mbox is None:
|
||||
self.sendNegativeResponse(tag, b"No such mailbox")
|
||||
return
|
||||
if "\\noselect" in [s.lower() for s in mbox.getFlags()]:
|
||||
self.sendNegativeResponse(tag, "Mailbox cannot be selected")
|
||||
return
|
||||
|
||||
flags = [imap4.networkString(flag) for flag in mbox.getFlags()]
|
||||
self.sendUntaggedResponse(b"%d EXISTS" % (mbox.getMessageCount(),))
|
||||
self.sendUntaggedResponse(b"%d RECENT" % (mbox.getRecentCount(),))
|
||||
self.sendUntaggedResponse(b"FLAGS (" + b" ".join(flags) + b")")
|
||||
self.sendPositiveResponse(
|
||||
None, b"[UIDVALIDITY %d]" % (mbox.getUIDValidity(),)
|
||||
)
|
||||
self.sendPositiveResponse(
|
||||
None, b"[UIDNEXT %d]" % (mbox.getUIDNext(),)
|
||||
)
|
||||
|
||||
s = mbox.isWriteable() and b"READ-WRITE" or b"READ-ONLY"
|
||||
mbox.addListener(self)
|
||||
self.sendPositiveResponse(
|
||||
tag, b"[" + s + b"] " + cmdName + b" successful"
|
||||
)
|
||||
self.state = "select"
|
||||
self.mbox = mbox
|
||||
|
||||
|
||||
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
|
||||
|
||||
def create(self, pathspec):
|
||||
"""Silently ignore mailbox creation requests from clients."""
|
||||
_logger.debug("Ignoring CREATE request for %s", pathspec)
|
||||
return False
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def select(self, name, readwrite=1):
|
||||
mbox = self.mailboxes.get(imap4._parseMbox(name.upper()))
|
||||
if mbox is not None:
|
||||
yield mbox._build_uid_index()
|
||||
return mbox
|
||||
|
||||
|
||||
@implementer(IRealm)
|
||||
class SimpleRealm:
|
||||
def requestAvatar(self, avatarId, mind, *interfaces):
|
||||
res = json.loads(avatarId)
|
||||
account = Account(res["username"], res["password"])
|
||||
account.addMailbox("INBOX")
|
||||
account.addMailbox("SENT")
|
||||
return imap4.IAccount, account, lambda: None
|
||||
username = res["username"]
|
||||
password = res["password"]
|
||||
|
||||
client = BackendClient(password)
|
||||
|
||||
inbox = SimpleMailbox("INBOX", client)
|
||||
sent = SimpleMailbox("SENT", client)
|
||||
|
||||
account = Account(username)
|
||||
account.mailboxes = {"INBOX": inbox, "SENT": sent}
|
||||
account.subscriptions = ["INBOX", "SENT"]
|
||||
|
||||
return imap4.IAccount, account, lambda: client.close()
|
||||
|
||||
|
||||
class IMAPFactory(protocol.Factory):
|
||||
def __init__(self, portal):
|
||||
def __init__(self, portal, context_factory=None):
|
||||
self.portal = portal
|
||||
self._context_factory = context_factory
|
||||
|
||||
def buildProtocol(self, addr):
|
||||
p = SimpleIMAPServer(self)
|
||||
p = SimpleIMAPServer(context_factory=self._context_factory)
|
||||
p.portal = self.portal
|
||||
return p
|
||||
|
||||
@@ -295,20 +131,77 @@ class IMAPFactory(protocol.Factory):
|
||||
class CustomChecker:
|
||||
credentialInterfaces = (IUsernamePassword,)
|
||||
|
||||
@staticmethod
|
||||
def _is_jwt(token: str) -> bool:
|
||||
"""Check if token looks like a JWT (eyJ... with 3 dot-separated parts)."""
|
||||
parts = token.split(".")
|
||||
return len(parts) == 3 and parts[0].startswith("eyJ")
|
||||
|
||||
def requestAvatarId(self, credentials):
|
||||
return defer.succeed(json.dumps({
|
||||
"username": credentials.username.decode(),
|
||||
"password": credentials.password.decode(),
|
||||
}))
|
||||
username = credentials.username.decode()
|
||||
password = credentials.password.decode()
|
||||
|
||||
if self._is_jwt(password):
|
||||
_logger.info("Login via JWT token")
|
||||
return defer.succeed(json.dumps({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}))
|
||||
|
||||
# Not a JWT — try address+password login via backend
|
||||
_logger.info("Login via address+password")
|
||||
d = threads.deferToThread(self._login_with_password, username, password)
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def _login_with_password(username: str, password: str) -> str:
|
||||
"""Exchange address+password for a JWT via backend."""
|
||||
res = httpx.post(
|
||||
f"{settings.proxy_url}/api/address_login",
|
||||
json={"email": username, "password": password},
|
||||
headers={
|
||||
"x-custom-auth": settings.basic_password,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=settings.imap_http_timeout,
|
||||
)
|
||||
if res.status_code == 200:
|
||||
jwt = res.json().get("jwt")
|
||||
if jwt:
|
||||
return json.dumps({
|
||||
"username": username,
|
||||
"password": jwt,
|
||||
})
|
||||
raise cred_error.UnauthorizedLogin(f"address_login failed: {res.status_code}")
|
||||
|
||||
|
||||
def start_imap_server():
|
||||
_logger.info(f"Starting IMAP server on port {settings.imap_port}")
|
||||
_logger.info("Starting IMAP server on port %s", settings.imap_port)
|
||||
|
||||
context_factory = None
|
||||
has_cert = bool(settings.imap_tls_cert)
|
||||
has_key = bool(settings.imap_tls_key)
|
||||
if has_cert != has_key:
|
||||
raise ValueError(
|
||||
"Both imap_tls_cert and imap_tls_key must be set together"
|
||||
)
|
||||
if has_cert and has_key:
|
||||
_logger.info("TLS enabled for IMAP (STARTTLS)")
|
||||
context_factory = ssl.DefaultOpenSSLContextFactory(
|
||||
settings.imap_tls_key,
|
||||
settings.imap_tls_cert,
|
||||
)
|
||||
|
||||
portal = Portal(SimpleRealm(), [CustomChecker()])
|
||||
reactor.listenTCP(settings.imap_port, IMAPFactory(portal))
|
||||
factory = IMAPFactory(portal, context_factory=context_factory)
|
||||
reactor.listenTCP(settings.imap_port, factory)
|
||||
reactor.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_logger.info(f"Starting server settings[{settings}]")
|
||||
_logger.info(
|
||||
"Starting IMAP server proxy_url=%s port=%s tls=%s",
|
||||
settings.proxy_url, settings.imap_port,
|
||||
bool(settings.imap_tls_cert and settings.imap_tls_key),
|
||||
)
|
||||
start_imap_server()
|
||||
|
||||
@@ -9,7 +9,10 @@ _logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
if __name__ == '__main__':
|
||||
_logger.info(f"Starting server settings[{settings}]")
|
||||
_logger.info(
|
||||
"Starting server proxy_url=%s smtp_port=%s imap_port=%s",
|
||||
settings.proxy_url, settings.port, settings.imap_port,
|
||||
)
|
||||
process_list = [
|
||||
multiprocessing.Process(target=start_smtp_server, args=()),
|
||||
multiprocessing.Process(target=start_imap_server, args=()),
|
||||
|
||||
@@ -19,7 +19,15 @@ def get_email_model(msg: Message):
|
||||
get_email_model(subpart)
|
||||
for subpart in msg.get_payload()
|
||||
] if msg.is_multipart() else []
|
||||
body = "" if msg.is_multipart() else msg._payload
|
||||
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")
|
||||
return EmailModel(
|
||||
headers={k: v for k, v in msg.items()},
|
||||
body=body,
|
||||
@@ -44,7 +52,12 @@ def parse_email(raw: str) -> EmailModel:
|
||||
)
|
||||
|
||||
|
||||
def generate_email_model(item: dict) -> EmailModel:
|
||||
def generate_email_model(item: dict) -> tuple[EmailModel, str]:
|
||||
"""Build an EmailModel from a sendbox item.
|
||||
|
||||
Returns (EmailModel, raw_mime_string) so callers can pass the
|
||||
synthesised MIME to SimpleMessage for correct BODY[] responses.
|
||||
"""
|
||||
email_json = json.loads(item["raw"])
|
||||
message = MIMEMultipart()
|
||||
if email_json.get("version") == "v2":
|
||||
@@ -66,4 +79,5 @@ def generate_email_model(item: dict) -> EmailModel:
|
||||
message["Date"] = datetime.datetime.strptime(
|
||||
item["created_at"], "%Y-%m-%d %H:%M:%S"
|
||||
).strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
return parse_email(message.as_string())
|
||||
raw_mime = message.as_string()
|
||||
return parse_email(raw_mime), raw_mime
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
aiosmtpd==1.4.6
|
||||
pydantic-settings==2.9.1
|
||||
requests==2.32.4
|
||||
pydantic-settings==2.13.1
|
||||
Twisted==25.5.0
|
||||
httpx==0.28.1
|
||||
pyOpenSSL==25.3.0
|
||||
service-identity==24.2.0
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import email
|
||||
import ssl
|
||||
|
||||
import httpx
|
||||
|
||||
from aiosmtpd.controller import Controller
|
||||
@@ -12,6 +14,15 @@ _logger = logging.getLogger(__name__)
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _safe_decode_payload(payload, charset):
|
||||
if payload is None:
|
||||
return ""
|
||||
try:
|
||||
return payload.decode(charset or "utf-8", errors="replace")
|
||||
except LookupError:
|
||||
return payload.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
class CustomSMTPHandler:
|
||||
|
||||
def authenticator(self, server, session, envelope, mechanism, auth_data):
|
||||
@@ -49,7 +60,7 @@ class CustomSMTPHandler:
|
||||
value = part.get_payload(decode=False)
|
||||
else:
|
||||
payload = part.get_payload(decode=True)
|
||||
value = payload.decode(charset) if charset else payload
|
||||
value = _safe_decode_payload(payload, charset)
|
||||
if not value:
|
||||
continue
|
||||
content_list.append({
|
||||
@@ -63,8 +74,8 @@ class CustomSMTPHandler:
|
||||
value = msg.get_payload(decode=False)
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
value = payload.decode(charset) if charset else payload
|
||||
_logger.info(f"Payload {msg._payload} charset {charset}")
|
||||
value = _safe_decode_payload(payload, charset)
|
||||
_logger.debug("Parsed content charset=%s", charset)
|
||||
content_list.append({
|
||||
"type": msg.get_content_type(),
|
||||
"value": value
|
||||
@@ -121,27 +132,41 @@ class CustomSMTPHandler:
|
||||
return '250 OK'
|
||||
|
||||
|
||||
handler = CustomSMTPHandler()
|
||||
server = Controller(
|
||||
handler,
|
||||
hostname="",
|
||||
port=settings.port,
|
||||
auth_require_tls=False,
|
||||
decode_data=True,
|
||||
authenticator=handler.authenticator,
|
||||
auth_exclude_mechanism=["DONT"]
|
||||
)
|
||||
def start_smtp_server():
|
||||
handler = CustomSMTPHandler()
|
||||
|
||||
tls_context = None
|
||||
has_cert = bool(settings.smtp_tls_cert)
|
||||
has_key = bool(settings.smtp_tls_key)
|
||||
if has_cert != has_key:
|
||||
raise ValueError(
|
||||
"Both smtp_tls_cert and smtp_tls_key must be set together"
|
||||
)
|
||||
if has_cert and has_key:
|
||||
_logger.info("TLS enabled for SMTP (STARTTLS)")
|
||||
tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
|
||||
tls_context.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3
|
||||
tls_context.load_cert_chain(settings.smtp_tls_cert, settings.smtp_tls_key)
|
||||
|
||||
async def start():
|
||||
_logger.info(f"Starting server on port {settings.port}")
|
||||
server = Controller(
|
||||
handler,
|
||||
hostname="",
|
||||
port=settings.port,
|
||||
auth_require_tls=bool(tls_context),
|
||||
decode_data=True,
|
||||
authenticator=handler.authenticator,
|
||||
auth_exclude_mechanism=["DONT"],
|
||||
tls_context=tls_context,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Starting SMTP server on port %s tls=%s",
|
||||
settings.port, bool(tls_context),
|
||||
)
|
||||
server.start()
|
||||
|
||||
|
||||
def start_smtp_server():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
task = loop.create_task(start())
|
||||
try:
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
@@ -150,5 +175,9 @@ def start_smtp_server():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_logger.info(f"Starting server settings[{settings}]")
|
||||
_logger.info(
|
||||
"Starting SMTP server proxy_url=%s port=%s tls=%s",
|
||||
settings.proxy_url, settings.port,
|
||||
bool(settings.smtp_tls_cert and settings.smtp_tls_key),
|
||||
)
|
||||
start_smtp_server()
|
||||
|
||||
@@ -4,21 +4,32 @@ import { en } from './en'
|
||||
|
||||
export default defineConfig({
|
||||
title: "Temp Mail Doc",
|
||||
description: 'CloudFlare 免费收发临时域名邮箱 | Free temporary domain email on CloudFlare',
|
||||
lang: 'zh-CN',
|
||||
lastUpdated: true,
|
||||
locales: {
|
||||
root: { label: '简体中文', ...zh },
|
||||
zh: { label: '简体中文', ...zh },
|
||||
en: { label: 'English', ...en }
|
||||
},
|
||||
head: [
|
||||
['link', { rel: 'icon', type: 'image/png', href: '/logo.png' }],
|
||||
['meta', { name: 'theme-color', content: '#5f67ee' }],
|
||||
['meta', { name: 'robots', content: 'index, follow' }],
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { property: 'og:locale', content: 'Temp Mail Doc' }],
|
||||
['meta', { property: 'og:title', content: 'Temp Mail Doc' }],
|
||||
['meta', { property: 'og:locale', content: 'zh_CN' }],
|
||||
['meta', { property: 'og:locale:alternate', content: 'en_US' }],
|
||||
['meta', { property: 'og:title', content: 'Temp Mail - CloudFlare 临时邮箱' }],
|
||||
['meta', { property: 'og:description', content: 'CloudFlare 免费收发临时域名邮箱,支持多域名、附件、Telegram Bot、Webhook、SMTP/IMAP' }],
|
||||
['meta', { property: 'og:site_name', content: 'Temp Mail' }],
|
||||
['meta', { property: 'og:image', content: 'https://temp-mail-docs.awsl.uk/logo.png' }],
|
||||
['meta', { property: 'og:url', content: 'https://temp-mail-docs.awsl.uk' }],
|
||||
['meta', { name: 'twitter:card', content: 'summary' }],
|
||||
['meta', { name: 'twitter:title', content: 'Temp Mail - CloudFlare 临时邮箱' }],
|
||||
['meta', { name: 'twitter:description', content: 'CloudFlare 免费收发临时域名邮箱' }],
|
||||
['meta', { name: 'twitter:image', content: 'https://temp-mail-docs.awsl.uk/logo.png' }],
|
||||
['link', { rel: 'alternate', hreflang: 'zh-Hans', href: 'https://temp-mail-docs.awsl.uk/zh/' }],
|
||||
['link', { rel: 'alternate', hreflang: 'en', href: 'https://temp-mail-docs.awsl.uk/en/' }],
|
||||
['link', { rel: 'alternate', hreflang: 'x-default', href: 'https://temp-mail-docs.awsl.uk/zh/' }],
|
||||
],
|
||||
sitemap: {
|
||||
hostname: 'https://temp-mail-docs.awsl.uk',
|
||||
|
||||
@@ -3,7 +3,12 @@ import { defineConfig, type DefaultTheme } from 'vitepress'
|
||||
export const en = defineConfig({
|
||||
title: "Temp Mail Doc",
|
||||
lang: 'en-US',
|
||||
description: 'CloudFlare Free sending and receiving of temporary domain name mailboxes',
|
||||
description: 'Free temporary domain email powered by CloudFlare Workers, with multi-domain, attachments, Telegram Bot, Webhook, SMTP/IMAP support',
|
||||
|
||||
head: [
|
||||
['meta', { property: 'og:locale', content: 'en_US' }],
|
||||
['meta', { property: 'og:description', content: 'Free temporary domain email powered by CloudFlare Workers, with multi-domain, attachments, Telegram Bot, Webhook, SMTP/IMAP support' }],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
nav: nav(),
|
||||
|
||||
@@ -3,7 +3,12 @@ import { defineConfig, type DefaultTheme } from 'vitepress'
|
||||
export const zh = defineConfig({
|
||||
title: "临时邮箱文档",
|
||||
lang: 'zh-Hans',
|
||||
description: 'CloudFlare 免费收发 临时域名邮箱',
|
||||
description: 'CloudFlare 免费收发临时域名邮箱,支持多域名、附件、Telegram Bot、Webhook、SMTP/IMAP',
|
||||
|
||||
head: [
|
||||
['meta', { property: 'og:locale', content: 'zh_CN' }],
|
||||
['meta', { property: 'og:description', content: 'CloudFlare 免费收发临时域名邮箱,支持多域名、附件、Telegram Bot、Webhook、SMTP/IMAP' }],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
nav: nav(),
|
||||
@@ -53,7 +58,7 @@ function nav(): DefaultTheme.NavItem[] {
|
||||
return [
|
||||
{
|
||||
text: '主页',
|
||||
link: '/',
|
||||
link: '/zh/',
|
||||
},
|
||||
{
|
||||
text: '指南',
|
||||
@@ -62,11 +67,11 @@ function nav(): DefaultTheme.NavItem[] {
|
||||
},
|
||||
{
|
||||
text: '服务状态',
|
||||
link: '/status',
|
||||
link: '/zh/status',
|
||||
},
|
||||
{
|
||||
text: '参考',
|
||||
link: '/reference',
|
||||
link: '/zh/reference',
|
||||
},
|
||||
{
|
||||
text: process.env.TAG_NAME || 'v0.2.2',
|
||||
@@ -165,6 +170,6 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
||||
{ text: 'Admin 用户管理', link: 'feature/admin-user-management' },
|
||||
]
|
||||
},
|
||||
{ text: '参考', base: "/", link: 'reference' }
|
||||
{ text: '参考', base: "/zh/", link: 'reference' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -51,10 +51,59 @@ services:
|
||||
- imap_port=11143
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `proxy_url` | `http://localhost:8787` | Worker backend URL |
|
||||
| `port` | `8025` | SMTP port |
|
||||
| `imap_port` | `11143` | IMAP port |
|
||||
| `smtp_tls_cert` | empty | SMTP TLS certificate file path (PEM), enables STARTTLS when configured |
|
||||
| `smtp_tls_key` | empty | SMTP TLS private key file path (PEM) |
|
||||
| `imap_tls_cert` | empty | IMAP TLS certificate file path (PEM), enables STARTTLS when configured |
|
||||
| `imap_tls_key` | empty | IMAP TLS private key file path (PEM) |
|
||||
| `imap_cache_size` | `500` | Max cached messages per mailbox |
|
||||
| `imap_http_timeout` | `30.0` | Backend HTTP request timeout (seconds) |
|
||||
|
||||
## Enabling STARTTLS
|
||||
|
||||
Configure the TLS certificate environment variables for SMTP and/or IMAP to enable STARTTLS support. SMTP and IMAP can share the same certificate.
|
||||
|
||||
```bash
|
||||
# .env example
|
||||
smtp_tls_cert=/path/to/cert.pem
|
||||
smtp_tls_key=/path/to/key.pem
|
||||
imap_tls_cert=/path/to/cert.pem
|
||||
imap_tls_key=/path/to/key.pem
|
||||
```
|
||||
|
||||
In Docker Compose:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- smtp_tls_cert=/certs/cert.pem
|
||||
- smtp_tls_key=/certs/key.pem
|
||||
- imap_tls_cert=/certs/cert.pem
|
||||
- imap_tls_key=/certs/key.pem
|
||||
volumes:
|
||||
- ./certs:/certs:ro
|
||||
```
|
||||
|
||||
## IMAP Login Methods
|
||||
|
||||
Two login methods are supported:
|
||||
|
||||
| Method | Username | Password | Description |
|
||||
|--------|----------|----------|-------------|
|
||||
| JWT Credential | Email address | JWT token | Address credential from frontend, direct authentication |
|
||||
| Address+Password | Email address | Address password | Verified via backend `/api/address_login` |
|
||||
|
||||
The system automatically detects the password format: a three-segment string starting with `eyJ` is treated as a JWT; otherwise it is treated as a password and verified via the backend.
|
||||
|
||||
## Using Thunderbird to Login
|
||||
|
||||
Download [Thunderbird](https://www.thunderbird.net/en-US/)
|
||||
|
||||
For password, enter the `email address credential`
|
||||
For password, enter the `email address credential` or `email address password`
|
||||
|
||||

|
||||
|
||||
@@ -11,7 +11,7 @@ res = requests.get(
|
||||
f"https://<your-worker-address>/api/mails?limit={limit}&offset={offset}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {your-JWT-password}",
|
||||
# "x-custom-auth": "<your-website-password>", # If custom password is enabled
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
@@ -33,7 +33,10 @@ querystring = {
|
||||
"address":"xxxx@awsl.uk"
|
||||
}
|
||||
|
||||
headers = {"x-admin-auth": "<your-Admin-password>"}
|
||||
headers = {
|
||||
"x-admin-auth": "<your-Admin-password>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=querystring)
|
||||
|
||||
@@ -42,6 +45,86 @@ print(response.json())
|
||||
|
||||
**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
|
||||
|
||||
Delete a single mail by mail ID.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
mail_id = 1
|
||||
url = f"https://<your-worker-address>/admin/mails/{mail_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<your-Admin-password>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## Admin Delete Address API
|
||||
|
||||
Delete an email address by address ID (also deletes associated mails, sender permissions, and user bindings).
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
address_id = 1
|
||||
url = f"https://<your-worker-address>/admin/delete_address/{address_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<your-Admin-password>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## Admin Clear Inbox API
|
||||
|
||||
Clear all received mails for an address by address ID.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
address_id = 1
|
||||
url = f"https://<your-worker-address>/admin/clear_inbox/{address_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<your-Admin-password>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## Admin Clear Sent Items API
|
||||
|
||||
Clear all sent mails for an address by address ID.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
address_id = 1
|
||||
url = f"https://<your-worker-address>/admin/clear_sent_items/{address_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<your-Admin-password>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## User Mail API
|
||||
|
||||
Supports `address` filter
|
||||
@@ -58,7 +141,10 @@ querystring = {
|
||||
"address":"xxxx@awsl.uk"
|
||||
}
|
||||
|
||||
headers = {"x-admin-auth": "<your-Admin-password>"}
|
||||
headers = {
|
||||
"x-user-token": "<your-user-JWT-token>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=querystring)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ res = requests.post(
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<your_website_admin_password>",
|
||||
# "x-custom-auth": "<your_website_password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
@@ -59,6 +60,7 @@ def fetch_email_data(name):
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<your_website_admin_password>",
|
||||
# "x-custom-auth": "<your_website_password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ res = requests.post(
|
||||
"http://localhost:8787/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Authorization": f"Bearer {your_JWT_password}",
|
||||
# "x-custom-auth": "<your_website_password>", # If custom password is enabled
|
||||
# "x-custom-auth": "<your_website_password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
@@ -36,6 +36,7 @@ send_body = {
|
||||
res = requests.post(
|
||||
"http://localhost:8787/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
# "x-custom-auth": "<your_website_password>", # If private site password is enabled
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -76,10 +76,18 @@ For other steps, refer to `Frontend and Backend Separation Deployment` in [UI De
|
||||
cd frontend
|
||||
pnpm install
|
||||
cp .env.example .env.prod
|
||||
# Edit .env.prod and set VITE_IS_TELEGRAM=true
|
||||
# --project-name can create a separate pages for mini app, you can also share one pages, but may encounter js loading issues
|
||||
pnpm run deploy:telegram --project-name=<your_project_name>
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Windows users: The inline `VITE_IS_TELEGRAM=true` environment variable in npm scripts does not work on Windows.
|
||||
> Please set `VITE_IS_TELEGRAM=true` in your `.env.prod` file manually, then use the regular build command instead:
|
||||
> ```bash
|
||||
> pnpm run build
|
||||
> ```
|
||||
|
||||
- After deployment, please fill in the web URL in the `Settings` -> `Telegram Mini App` page `Telegram Mini App URL` in the admin backend.
|
||||
- Please execute `/setmenubutton` in `@BotFather`, then enter your web address to set the `Open App` button in the lower left corner.
|
||||
- Please execute `/newapp` in `@BotFather` to create a new app and register the mini app.
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
|
||||
Reference: [Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
|
||||
|
||||
### Linux Do
|
||||
|
||||
- Create an application at [Linux Do Connect](https://connect.linux.do/) to obtain `Client ID` and `Client Secret`
|
||||
- Linux Do returns a user ID instead of an email, so you need to enable the Email Format feature
|
||||
|
||||
### Authentik
|
||||
|
||||
- [Authentik OAuth2 Provider](https://docs.goauthentik.io/docs/providers/oauth2/)
|
||||
@@ -21,6 +26,43 @@ Reference: [Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/bu
|
||||
|
||||

|
||||
|
||||
### Configuration Fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Name | OAuth2 provider name, displayed on the login page |
|
||||
| Client ID | OAuth2 application ID |
|
||||
| Client Secret | OAuth2 application secret |
|
||||
| Authorization URL | OAuth2 authorization endpoint |
|
||||
| Access Token URL | Endpoint to obtain Access Token |
|
||||
| Access Token Params Format | Token request format: `json` or `urlencoded` |
|
||||
| User Info URL | Endpoint to get user information |
|
||||
| User Email Key | Key for email field in user info, supports JSONPath (e.g., `$[0].email`) |
|
||||
| Redirect URL | OAuth2 callback URL |
|
||||
| Scope | OAuth2 permission scope |
|
||||
|
||||
### Email Format Transformation
|
||||
|
||||
When OAuth2 returns a non-standard email format (e.g., returns a user ID), you can enable the Email Format feature.
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Enable Email Format | Enable email format transformation |
|
||||
| Email Regex Pattern | Regular expression to match the original value, use capture groups `()` |
|
||||
| Replace Template | Replacement template, use `$1`, `$2`, etc. to reference capture groups |
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Scenario | Original Value | Regex Pattern | Replace Template | Result |
|
||||
|----------|---------------|---------------|------------------|--------|
|
||||
| ID to Email | `12345` | `^(.+)$` | `linux_do_$1@oauth.linux.do` | `linux_do_12345@oauth.linux.do` |
|
||||
| Change Domain | `john@old.com` | `^(.+)@old\.com$` | `$1@new.com` | `john@new.com` |
|
||||
| Extract Username | `john@corp.com` | `^(.+)@.*$` | `$1@mymail.com` | `john@mymail.com` |
|
||||
|
||||
### Email Address Allow List
|
||||
|
||||
When enabled, only emails from specified domains can login.
|
||||
|
||||
## Test User Login Page
|
||||
|
||||

|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
| `COPYRIGHT` | Text | Custom frontend footer text, supports html | `Dream Hunter` |
|
||||
| `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` |
|
||||
|
||||
|
||||
@@ -1,31 +1,11 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "临时邮箱文档"
|
||||
tagline: "搭建 CloudFlare 免费收发 临时域名邮箱"
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 立即试用
|
||||
link: https://mail.awsl.uk/
|
||||
- theme: alt
|
||||
text: 命令行部署
|
||||
link: /zh/guide/quick-start
|
||||
- theme: alt
|
||||
text: 通过用户界面部署
|
||||
link: /zh/guide/quick-start
|
||||
- theme: alt
|
||||
text: 通过 Github Actions 部署
|
||||
link: /zh/guide/quick-start
|
||||
|
||||
features:
|
||||
- title: 仅需域名即可私有部署, 免费托管在 CloudFlare,无需服务器
|
||||
details: 支持 password 登录邮箱, 用户注册,使用访问密码可作为私人站点,支持附件功能。
|
||||
- title: 使用 rust wasm 解析邮件
|
||||
details: 使用 rust wasm 解析邮件,支持邮件各种RFC标准,支持附件, 速度极快
|
||||
- title: 支持 Telegram Bot 和 Webhook
|
||||
details: 邮件可转发到 Telegram 或者 webhook, Telegram Bot 支持绑定邮箱,查看邮件, Telegram 小程序
|
||||
- title: 支持发送邮件(UI/API/SMTP)
|
||||
details: 支持通过域名邮箱发送 txt 或者 html 邮件,支持 DKIM 签名, UI/API/SMTP 发送邮件
|
||||
layout: page
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
window.location.replace('/zh/')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -51,10 +51,59 @@ services:
|
||||
- imap_port=11143
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `proxy_url` | `http://localhost:8787` | Worker 后端 URL |
|
||||
| `port` | `8025` | SMTP 端口 |
|
||||
| `imap_port` | `11143` | IMAP 端口 |
|
||||
| `smtp_tls_cert` | 空 | SMTP TLS 证书文件路径(PEM),配置后启用 STARTTLS |
|
||||
| `smtp_tls_key` | 空 | SMTP TLS 私钥文件路径(PEM) |
|
||||
| `imap_tls_cert` | 空 | IMAP TLS 证书文件路径(PEM),配置后启用 STARTTLS |
|
||||
| `imap_tls_key` | 空 | IMAP TLS 私钥文件路径(PEM) |
|
||||
| `imap_cache_size` | `500` | 每个邮箱的消息缓存上限 |
|
||||
| `imap_http_timeout` | `30.0` | 后端 HTTP 请求超时时间(秒) |
|
||||
|
||||
## 启用 STARTTLS
|
||||
|
||||
分别配置 SMTP 和 IMAP 的 TLS 证书环境变量后,对应服务会自动支持 STARTTLS。SMTP 和 IMAP 可以使用同一套证书。
|
||||
|
||||
```bash
|
||||
# .env 示例
|
||||
smtp_tls_cert=/path/to/cert.pem
|
||||
smtp_tls_key=/path/to/key.pem
|
||||
imap_tls_cert=/path/to/cert.pem
|
||||
imap_tls_key=/path/to/key.pem
|
||||
```
|
||||
|
||||
Docker Compose 中配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- smtp_tls_cert=/certs/cert.pem
|
||||
- smtp_tls_key=/certs/key.pem
|
||||
- imap_tls_cert=/certs/cert.pem
|
||||
- imap_tls_key=/certs/key.pem
|
||||
volumes:
|
||||
- ./certs:/certs:ro
|
||||
```
|
||||
|
||||
## IMAP 登录方式
|
||||
|
||||
支持两种登录方式:
|
||||
|
||||
| 方式 | 用户名 | 密码 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| JWT 凭证 | 邮箱地址 | JWT token | 从前端获取的地址凭证,直接认证 |
|
||||
| 地址+密码 | 邮箱地址 | 地址密码 | 通过后端 `/api/address_login` 验证 |
|
||||
|
||||
系统会自动识别密码格式:以 `eyJ` 开头的三段式字符串视为 JWT,其他视为密码并调用后端验证。
|
||||
|
||||
## 使用 Thunderbird 登录
|
||||
|
||||
下载 [Thunderbird](https://www.thunderbird.net/en-US/)
|
||||
|
||||
密码填写 `邮箱地址凭证`
|
||||
密码填写 `邮箱地址凭证` 或 `邮箱地址密码`
|
||||
|
||||

|
||||
|
||||
@@ -11,7 +11,7 @@ res = requests.get(
|
||||
f"https://<你的worker地址>/api/mails?limit={limit}&offset={offset}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
@@ -33,7 +33,10 @@ querystring = {
|
||||
"address":"xxxx@awsl.uk"
|
||||
}
|
||||
|
||||
headers = {"x-admin-auth": "<你的Admin密码>"}
|
||||
headers = {
|
||||
"x-admin-auth": "<你的Admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=querystring)
|
||||
|
||||
@@ -42,6 +45,86 @@ print(response.json())
|
||||
|
||||
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
|
||||
|
||||
## admin 删除邮件 API
|
||||
|
||||
通过邮件 ID 删除单封邮件。
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
mail_id = 1
|
||||
url = f"https://<你的worker地址>/admin/mails/{mail_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<你的Admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## admin 删除邮箱地址 API
|
||||
|
||||
通过邮箱地址 ID 删除邮箱地址(同时删除该地址关联的邮件、发件权限和用户绑定)。
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
address_id = 1
|
||||
url = f"https://<你的worker地址>/admin/delete_address/{address_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<你的Admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## admin 清空收件箱 API
|
||||
|
||||
通过邮箱地址 ID 清空该地址的所有收件。
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
address_id = 1
|
||||
url = f"https://<你的worker地址>/admin/clear_inbox/{address_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<你的Admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## admin 清空发件箱 API
|
||||
|
||||
通过邮箱地址 ID 清空该地址的所有发件。
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
address_id = 1
|
||||
url = f"https://<你的worker地址>/admin/clear_sent_items/{address_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<你的Admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
}
|
||||
|
||||
response = requests.delete(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## user 邮件 API
|
||||
|
||||
支持 `address` 过滤
|
||||
@@ -58,7 +141,10 @@ querystring = {
|
||||
"address":"xxxx@awsl.uk"
|
||||
}
|
||||
|
||||
headers = {"x-admin-auth": "<你的Admin密码>"}
|
||||
headers = {
|
||||
"x-user-token": "<你的用户JWT Token>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=querystring)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ res = requests.post(
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<你的网站admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
@@ -59,6 +60,7 @@ def fetch_email_data(name):
|
||||
},
|
||||
headers={
|
||||
'x-admin-auth': "<你的网站admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ res = requests.post(
|
||||
"http://localhost:8787/api/send_mail",
|
||||
json=send_body, headers={
|
||||
"Authorization": f"Bearer {你的JWT密码}",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了自定义密码
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
@@ -36,6 +36,7 @@ send_body = {
|
||||
res = requests.post(
|
||||
"http://localhost:8787/external/api/send_mail",
|
||||
json=send_body, headers={
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -76,10 +76,18 @@ Telegram Bot 支持中英文切换,用户可以通过 `/lang` 命令设置语
|
||||
cd frontend
|
||||
pnpm install
|
||||
cp .env.example .env.prod
|
||||
# 修改 .env.prod 文件,设置 VITE_IS_TELEGRAM=true
|
||||
# --project-name 可以单独为 mini app 创建一个 pages, 你也可以公用一个 pages,但是可能遇到 js 加载不了的问题
|
||||
pnpm run deploy:telegram --project-name=<你的项目名称>
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> Windows 用户请注意:`npm scripts` 中的 `VITE_IS_TELEGRAM=true` 内联环境变量写法在 Windows 上不生效。
|
||||
> 请在 `.env.prod` 文件中手动设置 `VITE_IS_TELEGRAM=true`,然后使用普通的 build 命令代替:
|
||||
> ```bash
|
||||
> pnpm run build
|
||||
> ```
|
||||
|
||||
- 部署完成后,请在 admin 后台的 `设置` -> `电报小程序` 页面 `电报小程序 URL` 中填写网页 URL。
|
||||
- 请在 `@BotFather` 处执行 `/setmenubutton`,然后输入你的网页地址,设置左下角的 `Open App` 按钮。
|
||||
- 请在 `@BotFather` 处执行 `/newapp` 新建 app 来注册 mini app。
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
|
||||
参考 [Creating an OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
|
||||
|
||||
### Linux Do
|
||||
|
||||
- 在 [Linux Do Connect](https://connect.linux.do/) 创建应用获取 `Client ID` 和 `Client Secret`
|
||||
- Linux Do 返回的是用户 ID 而非邮箱,需要启用邮箱格式转换功能
|
||||
|
||||
### Authentik
|
||||
|
||||
- [Authentik OAuth2 Provider](https://docs.goauthentik.io/docs/providers/oauth2/)
|
||||
@@ -21,6 +26,43 @@
|
||||
|
||||

|
||||
|
||||
### 配置字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| Name | OAuth2 提供商名称,显示在登录页面 |
|
||||
| Client ID | OAuth2 应用 ID |
|
||||
| Client Secret | OAuth2 应用密钥 |
|
||||
| Authorization URL | OAuth2 授权端点 |
|
||||
| Access Token URL | 获取 Access Token 的端点 |
|
||||
| Access Token Params Format | Token 请求格式:`json` 或 `urlencoded` |
|
||||
| User Info URL | 获取用户信息的端点 |
|
||||
| User Email Key | 用户信息中邮箱字段的 key,支持 JSONPath (如 `$[0].email`) |
|
||||
| Redirect URL | OAuth2 回调地址 |
|
||||
| Scope | OAuth2 权限范围 |
|
||||
|
||||
### 邮箱格式转换
|
||||
|
||||
当 OAuth2 返回的不是标准邮箱格式时(如返回用户 ID),可以启用邮箱格式转换功能。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| Enable Email Format | 启用邮箱格式转换 |
|
||||
| Email Regex Pattern | 正则表达式,用于匹配原始值,使用捕获组 `()` |
|
||||
| Replace Template | 替换模板,使用 `$1`、`$2` 等引用捕获组 |
|
||||
|
||||
**示例:**
|
||||
|
||||
| 场景 | 原始值 | 正则表达式 | 替换模板 | 结果 |
|
||||
|------|--------|-----------|----------|------|
|
||||
| ID 转邮箱 | `12345` | `^(.+)$` | `linux_do_$1@oauth.linux.do` | `linux_do_12345@oauth.linux.do` |
|
||||
| 换域名 | `john@old.com` | `^(.+)@old\.com$` | `$1@new.com` | `john@new.com` |
|
||||
| 提取用户名 | `john@corp.com` | `^(.+)@.*$` | `$1@mymail.com` | `john@mymail.com` |
|
||||
|
||||
### 邮件地址白名单
|
||||
|
||||
启用后,只有指定域名的邮箱才能登录。
|
||||
|
||||
## 测试用户登录页面
|
||||
|
||||

|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
| `COPYRIGHT` | 文本 | 自定义前端界面页脚文本,支持 html | `Dream Hunter` |
|
||||
| `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` |
|
||||
|
||||
|
||||
31
vitepress-docs/docs/zh/index.md
Normal file
31
vitepress-docs/docs/zh/index.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
# https://vitepress.dev/reference/default-theme-home-page
|
||||
layout: home
|
||||
|
||||
hero:
|
||||
name: "临时邮箱文档"
|
||||
tagline: "搭建 CloudFlare 免费收发 临时域名邮箱"
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 立即试用
|
||||
link: https://mail.awsl.uk/
|
||||
- theme: alt
|
||||
text: 命令行部署
|
||||
link: /zh/guide/quick-start
|
||||
- theme: alt
|
||||
text: 通过用户界面部署
|
||||
link: /zh/guide/quick-start
|
||||
- theme: alt
|
||||
text: 通过 Github Actions 部署
|
||||
link: /zh/guide/quick-start
|
||||
|
||||
features:
|
||||
- title: 仅需域名即可私有部署, 免费托管在 CloudFlare,无需服务器
|
||||
details: 支持 password 登录邮箱, 用户注册,使用访问密码可作为私人站点,支持附件功能。
|
||||
- title: 使用 rust wasm 解析邮件
|
||||
details: 使用 rust wasm 解析邮件,支持邮件各种RFC标准,支持附件, 速度极快
|
||||
- title: 支持 Telegram Bot 和 Webhook
|
||||
details: 邮件可转发到 Telegram 或者 webhook, Telegram Bot 支持绑定邮箱,查看邮件, Telegram 小程序
|
||||
- title: 支持发送邮件(UI/API/SMTP)
|
||||
details: 支持通过域名邮箱发送 txt 或者 html 邮件,支持 DKIM 签名, UI/API/SMTP 发送邮件
|
||||
---
|
||||
6
vitepress-docs/docs/zh/reference.md
Normal file
6
vitepress-docs/docs/zh/reference.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Reference
|
||||
|
||||
- https://developers.cloudflare.com/d1/
|
||||
- https://developers.cloudflare.com/pages/
|
||||
- https://developers.cloudflare.com/workers/
|
||||
- https://developers.cloudflare.com/email-routing/
|
||||
8
vitepress-docs/docs/zh/status.md
Normal file
8
vitepress-docs/docs/zh/status.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Status Page
|
||||
|
||||
[Status Link](https://uptime.aks.awsl.icu/status/temp-email)
|
||||
|
||||
| Service | Status |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) |       |
|
||||
| [Frontend](https://mail.awsl.uk/) |       |
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.0",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/node": "^25.3.3",
|
||||
"vitepress": "^1.6.4",
|
||||
"wrangler": "^4.59.2"
|
||||
"wrangler": "^4.70.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
|
||||
994
vitepress-docs/pnpm-lock.yaml
generated
994
vitepress-docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.2.1",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,24 +11,24 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260118.0",
|
||||
"@cloudflare/workers-types": "^4.20260305.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@simplewebauthn/types": "10.0.0",
|
||||
"@types/node": "^25.0.9",
|
||||
"@types/node": "^25.3.3",
|
||||
"eslint": "9.39.1",
|
||||
"globals": "^16.5.0",
|
||||
"typescript-eslint": "^8.53.0",
|
||||
"wrangler": "^4.59.2"
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"wrangler": "^4.70.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.888.0",
|
||||
"@simplewebauthn/server": "10.0.1",
|
||||
"hono": "^4.11.4",
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"mimetext": "^3.0.27",
|
||||
"hono": "^4.12.5",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"mimetext": "^3.0.28",
|
||||
"postal-mime": "^2.7.3",
|
||||
"resend": "^6.7.0",
|
||||
"resend": "^6.9.3",
|
||||
"telegraf": "4.16.3",
|
||||
"worker-mailer": "^1.2.1"
|
||||
},
|
||||
|
||||
1606
worker/pnpm-lock.yaml
generated
1606
worker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,8 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
@@ -178,6 +180,10 @@ export default {
|
||||
await c.env.DB.exec(`CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);`);
|
||||
}
|
||||
}
|
||||
if (version && version <= "v0.0.5") {
|
||||
// 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 != CONSTANTS.DB_VERSION) {
|
||||
// remove all \r and \n characters from the query string
|
||||
// split by ; and join with a ;\n
|
||||
|
||||
63
worker/src/admin_api/e2e_test_api.ts
Normal file
63
worker/src/admin_api/e2e_test_api.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Context } from 'hono'
|
||||
import { getBooleanValue } from '../utils'
|
||||
|
||||
// Direct DB insert — bypasses the email() handler.
|
||||
const seedMail = async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
||||
return c.text("Not available", 404);
|
||||
}
|
||||
const { address, source, raw, message_id } = await c.req.json();
|
||||
if (!address || !raw) {
|
||||
return c.text("address and raw are required", 400);
|
||||
}
|
||||
if (raw.length > 1_000_000) {
|
||||
return c.text("raw content too large", 400);
|
||||
}
|
||||
if (message_id && message_id.length > 255) {
|
||||
return c.text("message_id too long", 400);
|
||||
}
|
||||
const msgId = message_id || `<e2e-${Date.now()}@test>`;
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
|
||||
+ ` VALUES (?, ?, ?, ?, datetime('now'))`
|
||||
).bind(msgId, source || address, address, raw).run();
|
||||
return c.json({ success });
|
||||
};
|
||||
|
||||
// Exercises the real email() handler with a mock ForwardableEmailMessage.
|
||||
const receiveMail = async (c: Context<HonoCustomType>) => {
|
||||
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
||||
return c.text("Not available", 404);
|
||||
}
|
||||
const { from, to, raw } = await c.req.json();
|
||||
if (!from || !to || !raw) {
|
||||
return c.text("from, to and raw are required", 400);
|
||||
}
|
||||
|
||||
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
|
||||
const headerSection = raw.substring(0, Math.max(0, raw.indexOf('\r\n\r\n')));
|
||||
const headers = new Headers();
|
||||
for (const line of headerSection.replace(/\r\n(?=[ \t])/g, ' ').split('\r\n')) {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx > 0) headers.append(line.substring(0, idx).trim(), line.substring(idx + 1).trim());
|
||||
}
|
||||
if (!headers.has('Message-ID')) headers.set('Message-ID', `<e2e-${Date.now()}@test>`);
|
||||
|
||||
const rawBytes = new TextEncoder().encode(raw);
|
||||
let rejected: string | undefined;
|
||||
const mockMessage: ForwardableEmailMessage = {
|
||||
from, to, headers,
|
||||
rawSize: rawBytes.byteLength,
|
||||
raw: new ReadableStream({ start(ctrl) { ctrl.enqueue(rawBytes); ctrl.close(); } }),
|
||||
setReject(reason: string) { rejected = reason; },
|
||||
forward: async () => ({ messageId: '' }),
|
||||
reply: async () => ({ messageId: '' }),
|
||||
};
|
||||
|
||||
const { email: emailHandler } = await import('../email');
|
||||
await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} });
|
||||
|
||||
return c.json({ success: !rejected, ...(rejected ? { rejected } : {}) });
|
||||
};
|
||||
|
||||
export default { seedMail, receiveMail };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user