mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-05-12 02:20:12 +08:00
Compare commits
263 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e15b1b83d0 | ||
|
|
c297a49b2a | ||
|
|
de3f9e92ed | ||
|
|
832e996dd8 | ||
|
|
e81c9d0d9c | ||
|
|
163df5c908 | ||
|
|
c3058817ff | ||
|
|
16c4e43871 | ||
|
|
68cbfb9c32 | ||
|
|
e18285d3ef | ||
|
|
1584851a36 | ||
|
|
1cafbbf220 | ||
|
|
873a10ddb1 | ||
|
|
9689a1cbca | ||
|
|
ef475bab21 | ||
|
|
e6ef110ec9 | ||
|
|
42281cdc49 | ||
|
|
5248c03b6c | ||
|
|
b86d1faac4 | ||
|
|
a0db913952 | ||
|
|
4746983780 | ||
|
|
f08d062b32 | ||
|
|
8885948291 | ||
|
|
7c6d0d7c8a | ||
|
|
53c35062c8 | ||
|
|
1a7cfb8c95 | ||
|
|
d2c940aa2c | ||
|
|
db93828a81 | ||
|
|
be1bf71a47 | ||
|
|
424991a165 | ||
|
|
c97a9a278b | ||
|
|
a45d01f9fd | ||
|
|
03965f3612 | ||
|
|
64d11799b3 | ||
|
|
10f1f1f32b | ||
|
|
e77ab12140 | ||
|
|
79b9835fa2 | ||
|
|
6c58cd3c2e | ||
|
|
eeea512ab1 | ||
|
|
e35c246757 | ||
|
|
e7df77cac0 | ||
|
|
9ee21da8a9 | ||
|
|
5bb053fb7b | ||
|
|
7d880ef340 | ||
|
|
e6cc8e2ffd | ||
|
|
94c606959f | ||
|
|
75236e6a53 | ||
|
|
13c3879033 | ||
|
|
c5893a2944 | ||
|
|
5f3762ef58 | ||
|
|
10873e7887 | ||
|
|
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 | ||
|
|
e4c96c9868 | ||
|
|
2318e0f7e2 | ||
|
|
2cce3df213 | ||
|
|
a38a31a407 | ||
|
|
276093f113 | ||
|
|
36f8c4b3de | ||
|
|
8964d4461d | ||
|
|
a771446b9b | ||
|
|
50ab6756bd | ||
|
|
aee1f1942b | ||
|
|
3ebe22115a | ||
|
|
5e227d2b2d | ||
|
|
3b3968f3b4 | ||
|
|
499f65078b | ||
|
|
0d63142bd7 | ||
|
|
16ce1bf4e0 | ||
|
|
288eb38302 | ||
|
|
24366e2bff | ||
|
|
e5f62d4713 | ||
|
|
1836f931ee | ||
|
|
0f2836eebb | ||
|
|
b933aef7d9 | ||
|
|
fa72d7187f | ||
|
|
d15a4904a5 | ||
|
|
a25199eb34 | ||
|
|
7d485a7d0d | ||
|
|
abe812666f | ||
|
|
dbb55d948f | ||
|
|
a2a9f9e25f | ||
|
|
793901d349 | ||
|
|
113f9ad66b | ||
|
|
088bf3eefe | ||
|
|
024f9ba430 | ||
|
|
b337a44e62 | ||
|
|
eaeac8ebec | ||
|
|
7393519ba4 | ||
|
|
4ddc8e5c96 | ||
|
|
8b7ddae4f6 | ||
|
|
be36967b80 | ||
|
|
fac249ed31 | ||
|
|
bfd7d6811e | ||
|
|
b5c229b6c4 | ||
|
|
2728e9667b | ||
|
|
6109ab9e82 | ||
|
|
09a6cac8fe | ||
|
|
5f752c94f9 | ||
|
|
a2f3634c7e | ||
|
|
b62a3cbc3e | ||
|
|
8edb75587e | ||
|
|
de48661d0d | ||
|
|
a905ba5f06 | ||
|
|
6ae90be3bf | ||
|
|
5e24817de6 | ||
|
|
732189482e | ||
|
|
2bbde15f53 | ||
|
|
37cf0776b5 | ||
|
|
3fbace871c | ||
|
|
648e9f7adf | ||
|
|
ab2bfdd00f | ||
|
|
0565978930 | ||
|
|
89d8944e60 | ||
|
|
4084771621 | ||
|
|
840496c48f | ||
|
|
9843b35f54 | ||
|
|
bfd66f5019 | ||
|
|
0bc31360b0 | ||
|
|
267d9bb93e | ||
|
|
2cc84d565c | ||
|
|
c96d180591 | ||
|
|
1303b0f2a9 | ||
|
|
9f535a0a90 | ||
|
|
70109785c6 | ||
|
|
7fd10f2775 | ||
|
|
f59b8c7a1b | ||
|
|
312ac13185 | ||
|
|
e6c582be9f | ||
|
|
483c429feb | ||
|
|
da5482e095 | ||
|
|
de4646876a | ||
|
|
bbc8a96811 | ||
|
|
9ac9cd46b0 | ||
|
|
c694b07380 | ||
|
|
672c4c7273 | ||
|
|
ee023ac2e9 | ||
|
|
cc77bdf36d | ||
|
|
dec309a0fd | ||
|
|
9488543e44 | ||
|
|
50326bcc98 | ||
|
|
272b624b9b | ||
|
|
e230801a1c | ||
|
|
07833d5ca9 | ||
|
|
101a561894 | ||
|
|
327962432a | ||
|
|
6051d49315 | ||
|
|
95f361743b | ||
|
|
c6afc5d425 | ||
|
|
466f53254b | ||
|
|
ce0a10e6de | ||
|
|
26995982af | ||
|
|
0894ac0dc9 | ||
|
|
47e2cb56b4 | ||
|
|
32767176f0 | ||
|
|
31eb6c23d1 | ||
|
|
91a859bbcf | ||
|
|
525f5e2dce | ||
|
|
908fc0cc86 | ||
|
|
97d24b2087 | ||
|
|
983300acf4 | ||
|
|
144a792cb2 | ||
|
|
278f0112d0 | ||
|
|
764faebf9f | ||
|
|
d4f0c82e42 | ||
|
|
cf680e6349 | ||
|
|
c3987d364c | ||
|
|
3a542a8391 | ||
|
|
241e0b7b28 | ||
|
|
b43353ea47 | ||
|
|
6c334d32f6 | ||
|
|
7889d2edea | ||
|
|
2426e0b51a | ||
|
|
61434ab6f7 | ||
|
|
7f6a02ca38 | ||
|
|
6ae3b0d85e | ||
|
|
01e6cb1075 | ||
|
|
814f6fada2 | ||
|
|
31901aacc5 | ||
|
|
fb9b9f6ae4 | ||
|
|
095951ab45 | ||
|
|
37614ce6fa | ||
|
|
3f81fbee6d | ||
|
|
cf13236e7b | ||
|
|
36e9c611e6 | ||
|
|
047200c1c2 | ||
|
|
a22add0e14 | ||
|
|
7b1c4cc72a | ||
|
|
3870727a08 | ||
|
|
2bb033964c | ||
|
|
9db5a00b35 | ||
|
|
e161eb5d14 | ||
|
|
b604f56d56 | ||
|
|
52caf811f5 | ||
|
|
ee3884914b | ||
|
|
844fc52bbc | ||
|
|
b87b49f09d | ||
|
|
5bfa588f70 | ||
|
|
92620cdedb | ||
|
|
e9748be9fe | ||
|
|
479322c430 | ||
|
|
934e58e23b | ||
|
|
c964d77a59 | ||
|
|
8a03d3e57f | ||
|
|
6caba7c863 | ||
|
|
43e5bdc764 | ||
|
|
7bec0daba4 | ||
|
|
13e5adef17 | ||
|
|
440238133e | ||
|
|
4a881e2d2b | ||
|
|
b0bf7a5f13 | ||
|
|
a9bb8785ba | ||
|
|
0b48baff6d | ||
|
|
e0b5e80efd | ||
|
|
b0e36ac2aa | ||
|
|
51db19c85b | ||
|
|
e52b010aa4 | ||
|
|
8f6793402c | ||
|
|
e86c530116 | ||
|
|
0308f518da |
28
.claude/skills/release/SKILL.md
Normal file
28
.claude/skills/release/SKILL.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: release
|
||||
description: Create a GitHub release for cloudflare_temp_email project. Use when the user asks to create a release, publish a version, tag a release, or make a new release. Reads CHANGELOG.md for release content, collects merged PRs via `gh` CLI, and creates a properly formatted GitHub release.
|
||||
---
|
||||
|
||||
# Release Workflow
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Read version**: Get current version from `worker/package.json` (`"version"` field) and the latest release tag via `gh release list --limit 1`.
|
||||
2. **Read CHANGELOG**: Read `CHANGELOG.md` for the current version section (e.g. `## v1.4.0(main)`). Verify content matches `CHANGELOG_EN.md`. If entries are missing from either file, notify the user.
|
||||
3. **Collect PRs**: Get the last release tag timestamp, then filter merged PRs by time:
|
||||
```bash
|
||||
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
|
||||
SINCE="$(git show -s --format=%cI "$TAG")"
|
||||
gh pr list --state merged --search "is:pr is:merged merged:>$SINCE base:main" --json number,title,author --limit 200
|
||||
```
|
||||
Sort by PR number ascending.
|
||||
4. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules:
|
||||
- Write release body in **bilingual format**: Chinese sections first (from `CHANGELOG.md`), then wrap the English sections (from `CHANGELOG_EN.md`) in `<details><summary>English</summary>...</details>`.
|
||||
- Copy changelog sections verbatim from both files. Omit empty sections.
|
||||
- Wrap PRs list in `<details><summary>PRs</summary>...</details>`.
|
||||
- Always include the cache-clearing discussion link.
|
||||
- End with `**Full Changelog**` comparison link.
|
||||
5. **Create release**:
|
||||
- Write body to a temp file (e.g. `/tmp/release-notes.md`)
|
||||
- Run: `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes.md --target main`
|
||||
6. **Verify**: Confirm the release URL and ask the user to review.
|
||||
42
.claude/skills/release/references/release-template.md
Normal file
42
.claude/skills/release/references/release-template.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Release Notes Template
|
||||
|
||||
Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本段落提取:
|
||||
|
||||
```markdown
|
||||
## What's Changed
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |模块| 描述
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |模块| 描述
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |模块| 描述
|
||||
|
||||
### Improvements
|
||||
|
||||
- style/refactor/perf/docs: |模块| 描述
|
||||
|
||||
### [更新或者部署网页不生效请如图勾选清理缓存](https://github.com/dreamhunter2333/cloudflare_temp_email/discussions/487)
|
||||
|
||||
<details>
|
||||
<summary>PRs</summary>
|
||||
|
||||
* PR title by @author in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/NUMBER
|
||||
|
||||
</details>
|
||||
|
||||
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/vOLD...vNEW
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Sections without entries should be omitted
|
||||
- PRs section uses `<details>` to collapse by default
|
||||
- PRs are sorted by PR number ascending
|
||||
- The cache clearing discussion link is always included
|
||||
- Release title and tag use format `vX.Y.Z`
|
||||
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)` 之前插入(即语言切换链接 `</p>` 之后):
|
||||
```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
|
||||
3
.flake8
Normal file
3
.flake8
Normal file
@@ -0,0 +1,3 @@
|
||||
[flake8]
|
||||
max-line-length = 180
|
||||
exclude = .git,__pycache__,build,dist
|
||||
57
.github/config/mail-parser-wasm-worker.patch
vendored
Normal file
57
.github/config/mail-parser-wasm-worker.patch
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
diff --git a/worker/src/common.ts b/worker/src/common.ts
|
||||
index 9b758f0..e2150b5 100644
|
||||
--- a/worker/src/common.ts
|
||||
+++ b/worker/src/common.ts
|
||||
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
|
||||
}
|
||||
const raw_mail = parsedEmailContext.rawEmail;
|
||||
// NOTE: WASM parse email
|
||||
- // try {
|
||||
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
-
|
||||
- // const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
- // parsedEmailContext.parsedEmail = {
|
||||
- // sender: parsedEmail.sender || "",
|
||||
- // subject: parsedEmail.subject || "",
|
||||
- // text: parsedEmail.text || "",
|
||||
- // headers: parsedEmail.headers?.map(
|
||||
- // (header) => ({ key: header.key, value: header.value })
|
||||
- // ) || [],
|
||||
- // html: parsedEmail.body_html || "",
|
||||
- // attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
- // filename: att.filename || "attachment",
|
||||
- // mimeType: att.content_type || "application/octet-stream",
|
||||
- // content: att.content,
|
||||
- // disposition: "attachment",
|
||||
- // })),
|
||||
- // };
|
||||
- // return parsedEmailContext.parsedEmail;
|
||||
- // } catch (e) {
|
||||
- // console.error("Failed use mail-parser-wasm-worker to parse email", e);
|
||||
- // }
|
||||
+ try {
|
||||
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
|
||||
+
|
||||
+ const parsedEmail = parse_message_wrapper(raw_mail);
|
||||
+ parsedEmailContext.parsedEmail = {
|
||||
+ sender: parsedEmail.sender || "",
|
||||
+ subject: parsedEmail.subject || "",
|
||||
+ text: parsedEmail.text || "",
|
||||
+ headers: parsedEmail.headers?.map(
|
||||
+ (header) => ({ key: header.key, value: header.value })
|
||||
+ ) || [],
|
||||
+ html: parsedEmail.body_html || "",
|
||||
+ attachments: (parsedEmail.attachments || []).map(att => ({
|
||||
+ filename: att.filename || "attachment",
|
||||
+ mimeType: att.content_type || "application/octet-stream",
|
||||
+ content: att.content,
|
||||
+ disposition: "attachment",
|
||||
+ })),
|
||||
+ };
|
||||
+ return parsedEmailContext.parsedEmail;
|
||||
+ } catch (e) {
|
||||
+ console.error("Failed use mail-parser-wasm-worker to parse email", e);
|
||||
+ }
|
||||
try {
|
||||
const { default: PostalMime } = await import('postal-mime');
|
||||
const parsedEmail = await PostalMime.parse(raw_mail);
|
||||
59
.github/workflows/backend_deploy.yaml
vendored
59
.github/workflows/backend_deploy.yaml
vendored
@@ -1,6 +1,9 @@
|
||||
name: Deploy Backend Production
|
||||
name: Deploy Backend
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
@@ -13,32 +16,68 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Backend for ${{ github.ref_name }}
|
||||
run: |
|
||||
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
|
||||
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
|
||||
|
||||
if [ -n "$use_worker_assets" ]; then
|
||||
cd frontend/
|
||||
pnpm install --no-frozen-lockfile
|
||||
if [ -n "$use_worker_assets_with_telegram" ]; then
|
||||
echo "Building with telegram pages"
|
||||
pnpm build:telegram:pages
|
||||
else
|
||||
echo "Building with normal pages"
|
||||
pnpm build:pages
|
||||
fi
|
||||
cd ..
|
||||
fi
|
||||
|
||||
export debug_mode=${{ secrets.DEBUG_MODE }}
|
||||
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
|
||||
|
||||
cd worker/
|
||||
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
|
||||
# ✅ 修复核心:使用环境变量写入,避免 Shell 解析特殊字符
|
||||
printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml
|
||||
|
||||
pnpm install --no-frozen-lockfile
|
||||
output=$(pnpm run deploy 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
|
||||
if [ -n "$use_mail_wasm_parser" ]; then
|
||||
echo "Using mail-parser-wasm-worker"
|
||||
pnpm add mail-parser-wasm-worker
|
||||
git apply ../.github/config/mail-parser-wasm-worker.patch
|
||||
echo "Applied mail-parser-wasm-worker patch"
|
||||
fi
|
||||
|
||||
if [ "$debug_mode" = "true" ]; then
|
||||
pnpm run deploy
|
||||
else
|
||||
if pnpm run deploy >/dev/null 2>&1; then
|
||||
echo "Deploy succeeded"
|
||||
else
|
||||
code=$?
|
||||
echo "Command failed with exit code $code"
|
||||
exit $code
|
||||
exit "$code"
|
||||
fi
|
||||
fi
|
||||
echo "Deployed for tag ${{ github.ref_name }}"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
# ✅ 将 secret 映射到环境变量中
|
||||
WRANGLER_TOML_CONTENT: ${{ secrets.BACKEND_TOML }}
|
||||
|
||||
10
.github/workflows/docs_deploy.yml
vendored
10
.github/workflows/docs_deploy.yml
vendored
@@ -15,20 +15,20 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: check github release done
|
||||
|
||||
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@v6
|
||||
|
||||
- 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@v6
|
||||
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
|
||||
82
.github/workflows/frontend_deploy.yaml
vendored
82
.github/workflows/frontend_deploy.yaml
vendored
@@ -1,63 +1,85 @@
|
||||
name: Deploy Frontend
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Upstream Sync]
|
||||
types: [completed]
|
||||
push:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
tags:
|
||||
- "*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
deploy-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
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
|
||||
pnpm run deploy:actions --project-name=$FRONTEND_NAME --branch $frontend_branch
|
||||
else
|
||||
echo "Deploying branch prodcution"
|
||||
pnpm run deploy --project-name=$project_name
|
||||
echo "Deploying branch production"
|
||||
pnpm run deploy --project-name=$FRONTEND_NAME
|
||||
fi
|
||||
echo "Deploying prodcution 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
|
||||
else
|
||||
echo "Deploying telegram mini app branch prodcution"
|
||||
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@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
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,27 +1,48 @@
|
||||
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
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Deploy Frontend for ${{ github.ref_name }}
|
||||
@@ -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 }}
|
||||
|
||||
4
.github/workflows/pr_agent.yml
vendored
4
.github/workflows/pr_agent.yml
vendored
@@ -3,6 +3,7 @@ name: Codium PR Agent
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
issue_comment:
|
||||
jobs:
|
||||
pr_agent_job:
|
||||
if: ${{ github.event.sender.type != 'Bot' }}
|
||||
@@ -10,11 +11,12 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@main
|
||||
uses: qodo-ai/pr-agent@main
|
||||
env:
|
||||
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||
|
||||
17
.github/workflows/smtp_proxy_server.yml
vendored
17
.github/workflows/smtp_proxy_server.yml
vendored
@@ -21,28 +21,31 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set lowercase repository name
|
||||
run: echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./smtp_proxy_server
|
||||
file: ./smtp_proxy_server/dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
18
.github/workflows/sync.yaml
vendored
Normal file
18
.github/workflows/sync.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Upstream Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync_latest_from_upstream:
|
||||
name: Sync latest commits from upstream repo
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.repository.fork }}
|
||||
|
||||
steps:
|
||||
- name: Sync upstream changes
|
||||
run: gh repo sync ${{ github.repository }} --source dreamhunter2333/cloudflare_temp_email --branch main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
115
.github/workflows/tag_build.yml
vendored
115
.github/workflows/tag_build.yml
vendored
@@ -6,24 +6,21 @@ on:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Build Frontend
|
||||
@@ -32,22 +29,110 @@ jobs:
|
||||
- name: Zip Frontend dist
|
||||
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend
|
||||
path: frontend/frontend.zip
|
||||
|
||||
build-telegram-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
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@v7
|
||||
with:
|
||||
name: telegram-frontend
|
||||
path: frontend/telegram-frontend.zip
|
||||
|
||||
build-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: cp wrangler.toml
|
||||
run: cd worker && cp wrangler.toml.template wrangler.toml
|
||||
|
||||
- name: Build Backend
|
||||
run: cd worker && pnpm install --no-frozen-lockfile && pnpm build
|
||||
|
||||
- name: Upload to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
- name: Move worker.js
|
||||
run: cd worker/dist && mv worker.js ../
|
||||
|
||||
- name: Build Worker with wasm mail parser
|
||||
run: |
|
||||
cd worker
|
||||
echo "Using mail-parser-wasm-worker"
|
||||
pnpm add mail-parser-wasm-worker
|
||||
git apply ../.github/config/mail-parser-wasm-worker.patch
|
||||
echo "Applied mail-parser-wasm-worker patch"
|
||||
pnpm build
|
||||
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
|
||||
|
||||
- name: Upload worker.js
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
files: |
|
||||
frontend/frontend.zip
|
||||
frontend/telegram-frontend.zip
|
||||
worker/dist/worker.js
|
||||
name: worker-js
|
||||
path: worker/worker.js
|
||||
|
||||
- name: Upload wasm worker
|
||||
uses: actions/upload-artifact@v7
|
||||
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: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Upload Assets to Release
|
||||
run: |
|
||||
gh release upload "${{ github.ref_name }}" \
|
||||
artifacts/frontend/frontend.zip \
|
||||
artifacts/telegram-frontend/telegram-frontend.zip \
|
||||
artifacts/worker-js/worker.js \
|
||||
artifacts/worker-wasm/worker-with-wasm-mail-parser.zip \
|
||||
--clobber
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
dist/
|
||||
test/
|
||||
.vscode/
|
||||
@@ -136,3 +137,8 @@ dist
|
||||
wrangler.toml
|
||||
.dev.vars
|
||||
pnpm-lock.yaml
|
||||
|
||||
# E2E test artifacts
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
e2e/.e2e-pids
|
||||
|
||||
292
CHANGELOG.md
292
CHANGELOG.md
@@ -1,7 +1,287 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
## main(v0.8.0)
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.6.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| IP 黑名单设置新增 **IP 白名单(严格模式)**:启用后仅允许匹配白名单的 IP 访问受限流保护的 API(创建邮箱、发送邮件、外部发送邮件、用户注册、验证码校验),其他所有 IP 一律拒绝(#920)
|
||||
- feat: |Address| 支持最大地址数量设置为 `0` 表示无限制(#968)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 修复 `/admin/address` 与 `/admin/users` 在使用完整邮箱(query 长度超过 50 字节)作为搜索条件时报错 `D1_ERROR: LIKE or GLOB pattern too complex` 的问题,长查询自动改用 `instr()` 绕开 D1 的 LIKE pattern 长度限制(#956)
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |发送邮件 API| 明确 `/api/send_mail` 与 `/external/api/send_mail` 两个端点的认证方式差异,补充"地址 JWT"概念说明(#922)
|
||||
- docs: |Worker 变量| `JWT_SECRET` 补充生成方式说明(`openssl rand -hex 32`)(#932)
|
||||
- docs: |CLI 部署| `routes` 自定义域名配置增加用途说明(#932)
|
||||
- docs: |Admin API| `/admin/new_address` 返回值文档补充 `address_id` 字段(#912)
|
||||
- docs: |Admin| 补充管理后台账号列表排序功能说明(#918)
|
||||
- docs: |Pages 部署| 补充 SPA 模式说明,避免刷新页面或直接访问子路径时 404(#813)
|
||||
- docs: |侧边栏| 重组文档侧边栏结构,拆分为"核心配置"、"通知与集成"、"高级功能"、"管理后台"等分组
|
||||
- docs: |FAQ| 大幅扩充常见问题,新增 SPA 404、发信余额、SMTP_CONFIG 配置、邮件客户端登录等高频问题(#919, #925, #839, #715, #921, #609)
|
||||
- docs: |发送邮件| 增强 SMTP_CONFIG 字段说明和多域名示例,新增发信余额机制说明
|
||||
- docs: |Email Routing| 补充子域名需单独启用 Email Routing 的说明,避免仅在一级域名开启导致子域收不到邮件(#969)
|
||||
|
||||
## v1.5.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 管理后台账号列表支持按列排序(ID、名称、创建时间、更新时间、邮件数量、发送数量),搜索时自动重置分页到第1页(#918)
|
||||
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID(#912)
|
||||
- feat: |创建邮箱| 新增 `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 开关,并支持在管理后台单独控制创建邮箱 API 的子域名后缀匹配;开启后允许 `foo.example.com` 匹配基础域名 `example.com`
|
||||
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
|
||||
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767)
|
||||
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB),多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894)
|
||||
- feat: |邮件存储| 支持通过 `ENABLE_MAIL_GZIP` 变量启用 Gzip 压缩邮件存储(#823)
|
||||
- 启用前需先执行数据库迁移:`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema`,或调用接口 `POST /admin/db_migration`
|
||||
- 新邮件写入 `raw_blob`,兼容读取 `raw` / `raw_blob`;压缩与解压会增加 CPU 开销,建议付费 Worker Plan 再开启
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
|
||||
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900)
|
||||
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 新增创建邮箱子域名匹配测试,覆盖默认精确匹配、后台开启后生效,以及 env=false 的硬禁用优先级
|
||||
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |创建邮箱| 补充创建邮箱 API / Worker 变量 / 子域名文档,说明“直接指定子域名”和“随机子域名”两种能力的区别
|
||||
- docs: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910)
|
||||
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769)
|
||||
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
|
||||
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- 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
|
||||
|
||||
- fix: |定时任务| 修复定时任务清理报错 `e.get is not a function`,使用可选链安全访问 Context 方法
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |AI 提取| 暗色模式下 AI 提取信息使用更柔和的蓝色 (#A8C7FA),减少视觉疲劳
|
||||
|
||||
## v1.2.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- |数据库| 新增 `source_meta` 字段,需执行 `db/2025-12-27-source-meta.sql` 更新数据库或到 admin 维护页面点击数据库更新按钮
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 新增管理员账号页面,显示当前登录方式并支持退出登录(仅限密码登录方式)
|
||||
- fix: |GitHub Actions| 修复容器镜像名需要全部小写的问题
|
||||
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
|
||||
- feat: |地址来源| 新增地址来源追踪功能,记录地址创建来源(Web 记录 IP,Telegram 记录用户 ID,Admin 后台标记)
|
||||
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
|
||||
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
|
||||
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
|
||||
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
|
||||
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
|
||||
- feat: |Telegram| 机器人支持中英文切换,新增 `/lang` 命令设置语言偏好
|
||||
|
||||
## v1.1.0
|
||||
|
||||
- feat: |AI 提取| 增加 AI 邮件识别功能,使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
- 支持优先级提取:验证码 > 认证链接 > 服务链接 > 订阅链接 > 其他链接
|
||||
- 管理员可配置地址白名单(支持通配符,如 `*@example.com`)
|
||||
- 前端列表和详情页展示提取结果
|
||||
- 需要配置 `ENABLE_AI_EMAIL_EXTRACT` 环境变量和 AI 绑定
|
||||
- 需要执行 `db/2025-12-06-metadata.sql` 文件中的 SQL 更新 `D1` 数据库 或者到 admin维护页面点击数据库更新按钮
|
||||
- feat: |Admin| 维护页面增加清理 n 天前空邮件的邮箱地址功能
|
||||
- fix: 修复自定义认证密码功能异常的问题 (前端属性名错误 & /open_api 接口被拦截)
|
||||
|
||||
## v1.0.7
|
||||
|
||||
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
|
||||
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
|
||||
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
|
||||
|
||||
## v1.0.6
|
||||
|
||||
- feat: |DB| update db schema add index
|
||||
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
|
||||
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
|
||||
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
|
||||
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
|
||||
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
|
||||
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
|
||||
- feat: |UI| 主页增加进入极简模式按钮
|
||||
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- feat: |UI| 优化极简模式主页, 增加全部邮件页面功能(删除/下载/附件/...), 可在 `外观` 中切换
|
||||
- feat: admin 账号设置页面增加 `邮件转发规则` 配置
|
||||
- feat: admin 账号设置页面增加 `禁止接收未知地址邮件` 配置
|
||||
- feat: 邮件页面增加 上一封/下一封 按钮
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- fix: 修复 github actions 部署问题
|
||||
- feat: telegram /new 不指定域名时, 使用随机地址
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- fix: 修复 oauth2 登录失败的问题
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- feat: |UI| 增加极简模式主页, 可在 `外观` 中切换
|
||||
- fix: 修复 oauth2 登录时,default role 不生效的问题
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- fix: |UI| 修复 User 查看收件箱,不选择地址时,关键词查询不生效
|
||||
- fix: 修复自动清理任务,时间为 0 时不生效的问题
|
||||
- feat: 清理功能增加 创建 n 天前地址清理,n 天前未活跃地址清理
|
||||
- fix: |IMAP Proxy| 修复 IMAP Proxy 服务器,无法查看新邮件的问题
|
||||
|
||||
## v0.10.0
|
||||
|
||||
- feat: 支持 User 查看收件箱,`/user_api/mails` 接口, 支持 `address` 和 `keyword` 过滤
|
||||
- fix: 修复 Oauth2 登录获取 Token 时,一些 Oauth2 需要 `redirect_uri` 参数的问题
|
||||
- feat: 用户访问网页时,如果 `user token` 在 7 天内过期,自动刷新
|
||||
- feat: admin portal 中增加初始化 db 的功能
|
||||
- feat: 增加 `ALWAYS_SHOW_ANNOUNCEMENT` 变量,用于配置是否总是显示公告
|
||||
|
||||
## v0.9.1
|
||||
|
||||
- feat: |UI| support google ads
|
||||
- feat: |UI| 使用 shadow DOM 防止样式污染
|
||||
- feat: |UI| 支持 URL jwt 参数自动登录邮箱,jwt 参数会覆盖浏览器中的 jwt
|
||||
- fix: |CleanUP| 修复清理邮件时,清理时间超过 30 天报错的 bug
|
||||
- feat: admin 用户管理页面: 增加 用户地址查看功能
|
||||
- feat: | S3 附件| 增加 S3 附件删除功能
|
||||
- feat: | Admin API| 增加 admin 绑定用户和地址的 api
|
||||
- feat: | Oauth2 | Oatuh2 获取用户信息时,支持 `JSONPATH` 表达式
|
||||
|
||||
## v0.9.0
|
||||
|
||||
- feat: | Worker | 支持多语言
|
||||
- feat: | Worker | `NO_LIMIT_SEND_ROLE` 配置支持多角色, 逗号分割
|
||||
- feat: | Actions | build 里增加 `worker-with-wasm-mail-parser.zip` 支持 UI 部署带 `wasm` 的 worker
|
||||
|
||||
## v0.8.7
|
||||
|
||||
- fix: |UI| 修复移动设备日期显示问题
|
||||
- feat: |Worker| 支持通过 `SMTP` 发送邮件, 使用 [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
|
||||
## v0.8.6
|
||||
|
||||
- feat: |UI| 公告支持 html 格式
|
||||
- feat: |UI| `COPYRIGHT` 支持 html 格式
|
||||
- feat: |Doc| 优化部署文档,补充了 `Github Actions 部署文档`,增加了 `Worker 变量说明`
|
||||
|
||||
## v0.8.5
|
||||
|
||||
- feat: |mail-parser-wasm-worker| 修复 `initSync` 函数调用时的 `deprecated` 参数警告
|
||||
- feat: rpc headers covert & typo (#559)
|
||||
- fix: telegram mail page use iframe show email (#561)
|
||||
- feat: |Worker| 增加 `REMOVE_ALL_ATTACHMENT` 和 `REMOVE_EXCEED_SIZE_ATTACHMENT` 用于移除邮件附件,由于是解析邮件的一些信息会丢失,比如图片等.
|
||||
|
||||
## v0.8.4
|
||||
|
||||
- fix: |UI| 修复 admin portal 无收件人邮箱删除调用api 错误
|
||||
- feat: |Telegram Bot| 增加 telegram bot 清理无效地址凭证命令
|
||||
- feat: 增加 worker 配置 `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` 禁用匿名用户创建邮箱地址,只允许登录用户创建邮箱地址
|
||||
- feat: 增加 worker 配置 `ENABLE_ANOTHER_WORKER` 及 `ANOTHER_WORKER_LIST` ,用于调用其他 worker 的 rpc 接口 (#547)
|
||||
- feat: |UI| 自动刷新配置保存到浏览器,可配置刷新间隔
|
||||
- feat: 垃圾邮件检测增加存在时才检查的列表 `JUNK_MAIL_CHECK_LIST` 配置
|
||||
- feat: | Worker | 增加 `ParsedEmailContext` 类用于缓存解析后的邮件内容,减少解析次数
|
||||
- feat: |Github Action| Worker 部署增加 `DEBUG_MODE` 输出日志, `BACKEND_USE_MAIL_WASM_PARSER` 配置是否使用 wasm 解析邮件
|
||||
|
||||
## v0.8.3
|
||||
|
||||
- feat: |Github Action| 增加自动更新并部署功能
|
||||
- feat: |UI| admin 用户设置,支持 oauth2 配置的删除
|
||||
- feat: 增加垃圾邮件检测必须通过的列表 `JUNK_MAIL_FORCE_PASS_LIST` 配置
|
||||
|
||||
## v0.8.2
|
||||
|
||||
- fix: |Doc| 修复文档中的一些错误
|
||||
- fix: |Github Action| 修复 frontend 部署分支错误的问题
|
||||
- feat: admin 发送邮件功能
|
||||
- feat: admin 后台,账号配置页面添加无限发送邮件的地址列表
|
||||
|
||||
## v0.8.1
|
||||
|
||||
- feat: |Doc| 更新 UI 安装的文档
|
||||
- feat: |UI| 对用户隐藏邮箱账号的 ID
|
||||
- feat: |UI| 增加邮件详情页的 `转发` 按钮
|
||||
|
||||
## v0.8.0
|
||||
|
||||
- feat: |UI| 随机生成地址时不超过最大长度
|
||||
- feat: |UI| 邮件时间显示浏览器时区,可在设置中切换显示为 UTC 时间
|
||||
@@ -9,6 +289,14 @@
|
||||
|
||||
## v0.7.6
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
UI 部署 worker 需要点击 Settings -> Runtime, 修改 Compatibility flags, 增加 `nodejs_compat`
|
||||
|
||||

|
||||
|
||||
### Changes
|
||||
|
||||
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
|
||||
- feat: 增加 telegram mini app 的 build 压缩包
|
||||
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置
|
||||
|
||||
700
CHANGELOG_EN.md
Normal file
700
CHANGELOG_EN.md
Normal file
@@ -0,0 +1,700 @@
|
||||
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
|
||||
# CHANGE LOG
|
||||
|
||||
<p align="center">
|
||||
<a href="CHANGELOG.md">中文</a> |
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.6.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add **IP Whitelist (strict mode)** to IP blacklist settings: when enabled, ONLY whitelisted IPs can access rate-limited APIs (create address, send mail, external send mail, user register, verify code); all other IPs are denied (#920)
|
||||
- feat: |Address| Support setting max address count to `0` for unlimited (#968)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Fix `D1_ERROR: LIKE or GLOB pattern too complex` on `/admin/address` and `/admin/users` when searching by full email address (query length pushes the LIKE pattern over D1's 50-byte limit). Long queries now fall back to `instr()` to bypass the LIKE pattern length cap (#956)
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |Send Mail API| Clarify authentication differences between `/api/send_mail` and `/external/api/send_mail`, add "Address JWT" concept explanation (#922)
|
||||
- docs: |Worker Variables| Add generation instructions for `JWT_SECRET` (`openssl rand -hex 32`) (#932)
|
||||
- docs: |CLI Deployment| Add usage explanation for `routes` custom domain configuration (#932)
|
||||
- docs: |Admin API| Add `address_id` field to `/admin/new_address` response documentation (#912)
|
||||
- docs: |Admin| Add account list sorting feature documentation (#918)
|
||||
- docs: |Pages Deployment| Add SPA mode instructions to avoid 404 when refreshing or accessing sub-paths directly (#813)
|
||||
- docs: |Sidebar| Restructure documentation sidebar into "Core Configuration", "Notifications & Integrations", "Advanced Features", "Admin Console" groups
|
||||
- docs: |FAQ| Significantly expand FAQ with SPA 404, send balance, SMTP_CONFIG, mail client login and more (#919, #925, #839, #715, #921, #609)
|
||||
- docs: |Email Sending| Enhance SMTP_CONFIG field reference and multi-domain examples, add send balance mechanism documentation
|
||||
- docs: |Email Routing| Note that subdomains require Email Routing to be enabled separately; enabling it only on the apex domain does not cover subdomains (#969)
|
||||
|
||||
## v1.5.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Admin account list now supports column sorting (ID, name, created at, updated at, mail count, send count), search automatically resets pagination to page 1 (#918)
|
||||
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
|
||||
- feat: |Create Address| Add `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` switch and an admin-panel toggle for suffix-based subdomain matching in create-address APIs; when enabled, `foo.example.com` can match base domain `example.com`
|
||||
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
|
||||
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
|
||||
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
|
||||
- feat: |Mail Storage| Support enabling gzip-compressed email storage via `ENABLE_MAIL_GZIP` variable (#823)
|
||||
- Run database migration before enabling it: `Admin -> Quick Setup -> Database -> Migrate Database`, or call `POST /admin/db_migration`
|
||||
- New emails are stored in `raw_blob` and reads stay compatible with `raw` / `raw_blob`; compression and decompression add CPU overhead, so a paid Worker plan is recommended
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Auto Reply| Fix auto-reply not triggering when `source_prefix` is empty string (#459), empty value now correctly matches all senders
|
||||
- fix: |OAuth2| Fix OAuth2 login callback failure on Android via browser and other mobile browsers due to sessionStorage loss during redirect, add localStorage fallback (#900)
|
||||
- fix: |IMAP| Fix nested reply email mojibake, Gmail empty Content-Type header parsing failure, missing Date header, and locale-dependent date formatting issues
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Add create-address subdomain matching tests covering default exact-match behavior, admin-enabled matching, and env=false hard-disable precedence
|
||||
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
|
||||
|
||||
### Docs
|
||||
|
||||
- docs: |Create Address| Update create-address API, worker variables, and subdomain docs to clarify the difference between explicitly specified subdomains and random subdomains
|
||||
- docs: |API| Add clarification between Address JWT and User JWT to avoid confusion; reorganize documentation menu structure with dedicated API Endpoints section (#910)
|
||||
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
|
||||
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
|
||||
- feat: |Webhook| Add Telegram Bot, WeChat Work, Discord preset templates to frontend webhook settings
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- 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
|
||||
|
||||
- fix: |Scheduled Tasks| Fix scheduled task cleanup error `e.get is not a function`, use optional chaining for safe access to Context methods
|
||||
|
||||
### Improvements
|
||||
|
||||
- style: |AI Extraction| Use softer blue color (#A8C7FA) for AI extraction info in dark mode to reduce eye strain
|
||||
|
||||
## v1.2.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- |Database| Add `source_meta` field, need to execute `db/2025-12-27-source-meta.sql` to update database or click database update button on admin maintenance page
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add admin account page, display current login method and support logout (password login only)
|
||||
- fix: |GitHub Actions| Fix container image name must be lowercase
|
||||
- feat: |Email Forwarding| Add source address regex forwarding, filter by sender address, fully backward compatible
|
||||
- feat: |Address Source| Add address source tracking feature, record address creation source (Web records IP, Telegram records user ID, Admin panel marked)
|
||||
- feat: |Email Filtering| Remove backend keyword parameter, switch to frontend filtering of current page emails, optimize query performance
|
||||
- feat: |Frontend| Unify address switching into a dropdown component, support switching in simple mode, add address management entry on the homepage
|
||||
- feat: |Database| Add index for `message_id` field to optimize email update operations, need to execute `db/2025-12-15-message-id-index.sql` to update database
|
||||
- feat: |Admin| Add custom SQL cleanup feature to maintenance page, support scheduled task execution of custom cleanup statements
|
||||
- feat: |i18n| Backend API error messages now fully support Chinese and English internationalization
|
||||
- feat: |Telegram| Bot supports Chinese/English switching, add `/lang` command to set language preference
|
||||
|
||||
## v1.1.0
|
||||
|
||||
- feat: |AI Extraction| Add AI email recognition feature, use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
|
||||
- Support priority extraction: verification codes > authentication links > service links > subscription links > other links
|
||||
- Admin can configure address whitelist (supports wildcards, e.g. `*@example.com`)
|
||||
- Frontend list and detail pages display extraction results
|
||||
- Need to configure `ENABLE_AI_EMAIL_EXTRACT` environment variable and AI binding
|
||||
- Need to execute SQL in `db/2025-12-06-metadata.sql` file to update `D1` database or click database update button on admin maintenance page
|
||||
- feat: |Admin| Add feature to cleanup addresses with empty mailboxes older than n days on maintenance page
|
||||
- fix: Fix custom authentication password function issue (frontend property name error & /open_api interface blocked)
|
||||
|
||||
## v1.0.7
|
||||
|
||||
- feat: |Admin| Add IP blacklist feature for limiting high-frequency API access
|
||||
- feat: |Admin| Add ASN organization blacklist feature, support filtering requests based on ASN organization name (supports text matching and regex)
|
||||
- feat: |Admin| Add browser fingerprint blacklist feature, support filtering requests based on browser fingerprint (supports exact matching and regex)
|
||||
|
||||
## v1.0.6
|
||||
|
||||
- feat: |DB| Update db schema add index
|
||||
- feat: |Address Password| Add address password login feature, enabled via `ENABLE_ADDRESS_PASSWORD` configuration, need to execute SQL in `db/2025-09-23-patch.sql` file to update `D1` database
|
||||
- fix: |GitHub Actions| Fix debug mode configuration, only enable debug mode when DEBUG_MODE is 'true'
|
||||
- feat: |Admin| Account management page adds multi-select batch operations (batch delete, batch clear inbox, batch clear outbox)
|
||||
- feat: |Admin| Maintenance page adds feature to cleanup unbound user addresses
|
||||
- feat: Support configuring different bound address quantity limits for different roles, configurable in admin page
|
||||
|
||||
## v1.0.5
|
||||
|
||||
- feat: Add `DISABLE_CUSTOM_ADDRESS_NAME` configuration: disable custom email address name feature
|
||||
- feat: Add `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` configuration: prioritize first domain when creating addresses
|
||||
- feat: |UI| Add button to enter minimalist mode on homepage
|
||||
- feat: |Webhook| Add whitelist switch feature, support flexible access control
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- feat: |UI| Optimize minimalist mode homepage, add all emails page functionality (delete/download/attachments/...), switchable in `Appearance`
|
||||
- feat: Admin account settings page adds `Email Forwarding Rules` configuration
|
||||
- feat: Admin account settings page adds `Reject Unknown Address Emails` configuration
|
||||
- feat: Email page adds Previous/Next buttons
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- fix: Fix github actions deployment issue
|
||||
- feat: telegram /new when domain not specified, use random address
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- fix: Fix oauth2 login failure issue
|
||||
|
||||
## v1.0.1
|
||||
|
||||
- feat: |UI| Add minimalist mode homepage, switchable in `Appearance`
|
||||
- fix: Fix oauth2 login default role not taking effect issue
|
||||
|
||||
## v1.0.0
|
||||
|
||||
- fix: |UI| Fix User inbox viewing, when address not selected, keyword query not working
|
||||
- fix: Fix auto cleanup task, time 0 not taking effect issue
|
||||
- feat: Cleanup feature adds cleanup of addresses created n days ago, cleanup of addresses inactive for n days
|
||||
- fix: |IMAP Proxy| Fix IMAP Proxy server unable to view new emails issue
|
||||
|
||||
## v0.10.0
|
||||
|
||||
- feat: Support User inbox viewing, `/user_api/mails` interface, support `address` and `keyword` filtering
|
||||
- fix: Fix Oauth2 login token retrieval, some Oauth2 require `redirect_uri` parameter issue
|
||||
- feat: When user accesses webpage, if `user token` expires within 7 days, auto refresh
|
||||
- feat: Add db initialization feature to admin portal
|
||||
- feat: Add `ALWAYS_SHOW_ANNOUNCEMENT` variable to configure whether to always show announcements
|
||||
|
||||
## v0.9.1
|
||||
|
||||
- feat: |UI| Support google ads
|
||||
- feat: |UI| Use shadow DOM to prevent style pollution
|
||||
- feat: |UI| Support URL jwt parameter auto-login to mailbox, jwt parameter overrides browser jwt
|
||||
- fix: |CleanUP| Fix cleanup emails when cleanup time exceeds 30 days error bug
|
||||
- feat: Admin user management page: add user address viewing feature
|
||||
- feat: | S3 Attachments| Add S3 attachment deletion feature
|
||||
- feat: | Admin API| Add admin bind user and address api
|
||||
- feat: | Oauth2 | When Oauth2 gets user info, support `JSONPATH` expressions
|
||||
|
||||
## v0.9.0
|
||||
|
||||
- feat: | Worker | Support multi-language
|
||||
- feat: | Worker | `NO_LIMIT_SEND_ROLE` configuration supports multiple roles, comma separated
|
||||
- feat: | Actions | Add `worker-with-wasm-mail-parser.zip` in build to support UI deployment with `wasm` worker
|
||||
|
||||
## v0.8.7
|
||||
|
||||
- fix: |UI| Fix mobile device date display issue
|
||||
- feat: |Worker| Support sending emails via `SMTP`, using [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
|
||||
|
||||
## v0.8.6
|
||||
|
||||
- feat: |UI| Announcements support html format
|
||||
- feat: |UI| `COPYRIGHT` supports html format
|
||||
- feat: |Doc| Optimize deployment documentation, supplement `Github Actions Deployment Documentation`, add `Worker Variable Description`
|
||||
|
||||
## v0.8.5
|
||||
|
||||
- feat: |mail-parser-wasm-worker| Fix `deprecated` parameter warning when calling `initSync` function
|
||||
- feat: rpc headers convert & typo (#559)
|
||||
- fix: telegram mail page use iframe show email (#561)
|
||||
- feat: |Worker| Add `REMOVE_ALL_ATTACHMENT` and `REMOVE_EXCEED_SIZE_ATTACHMENT` for removing email attachments, due to parsing emails some information will be lost, such as images.
|
||||
|
||||
## v0.8.4
|
||||
|
||||
- fix: |UI| Fix admin portal delete call api error when no recipient email
|
||||
- feat: |Telegram Bot| Add telegram bot cleanup invalid address credentials command
|
||||
- feat: Add worker configuration `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` to disable anonymous user email creation, only allow logged-in users to create email addresses
|
||||
- feat: Add worker configuration `ENABLE_ANOTHER_WORKER` and `ANOTHER_WORKER_LIST`, for calling other worker rpc interfaces (#547)
|
||||
- feat: |UI| Auto refresh configuration saved to browser, configurable refresh interval
|
||||
- feat: Spam detection adds check-when-exists list `JUNK_MAIL_CHECK_LIST` configuration
|
||||
- feat: | Worker | Add `ParsedEmailContext` class for caching parsed email content, reduce parsing times
|
||||
- feat: |Github Action| Worker deployment adds `DEBUG_MODE` output logging, `BACKEND_USE_MAIL_WASM_PARSER` configuration for whether to use wasm to parse emails
|
||||
|
||||
## v0.8.3
|
||||
|
||||
- feat: |Github Action| Add auto update and deploy feature
|
||||
- feat: |UI| Admin user settings, support oauth2 configuration deletion
|
||||
- feat: Add spam detection must-pass list `JUNK_MAIL_FORCE_PASS_LIST` configuration
|
||||
|
||||
## v0.8.2
|
||||
|
||||
- fix: |Doc| Fix some documentation errors
|
||||
- fix: |Github Action| Fix frontend deployment branch error issue
|
||||
- feat: Admin send email feature
|
||||
- feat: Admin backend, account configuration page adds unlimited send email address list
|
||||
|
||||
## v0.8.1
|
||||
|
||||
- feat: |Doc| Update UI installation documentation
|
||||
- feat: |UI| Hide mailbox account ID from users
|
||||
- feat: |UI| Add `Forward` button to email detail page
|
||||
|
||||
## v0.8.0
|
||||
|
||||
- feat: |UI| Random address generation doesn't exceed max length
|
||||
- feat: |UI| Email time display in browser timezone, can switch to display UTC time in settings
|
||||
- feat: Support transferring emails to other users
|
||||
|
||||
## v0.7.6
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
UI deployment worker needs to click Settings -> Runtime, modify Compatibility flags, add `nodejs_compat`
|
||||
|
||||

|
||||
|
||||
### Changes
|
||||
|
||||
- feat: Support pre-setting bot info to reduce telegram callback latency (#441)
|
||||
- feat: Add telegram mini app build archive
|
||||
- feat: Add whether to enable spam check `ENABLE_CHECK_JUNK_MAIL` configuration
|
||||
|
||||
## v0.7.5
|
||||
|
||||
- fix: Fix `name` validation check
|
||||
|
||||
## v0.7.4
|
||||
|
||||
- feat: UI list page adds minimum width
|
||||
- fix: Fix `name` validation check
|
||||
- fix: Fix `DEFAULT_DOMAINS` configuration empty not taking effect issue
|
||||
|
||||
## v0.7.3
|
||||
|
||||
- feat: Worker adds `ADDRESS_CHECK_REGEX`, address name regex, only for checking, matching will pass check
|
||||
- fix: UI fix login page tab active icon misalignment
|
||||
- fix: UI fix admin page refresh popup password input issue
|
||||
- feat: Support `OAuth2` login, can login via `Github` `Authentik` and other third parties, see details [OAuth2 Third-party Login](https://temp-mail-docs.awsl.uk/en/guide/feature/user-oauth2.html)
|
||||
|
||||
## v0.7.2
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
`webhook` structure adds `enabled` field, existing configurations need to be re-enabled and saved on the page.
|
||||
|
||||
### Changes
|
||||
|
||||
- fix: Worker adds `NO_LIMIT_SEND_ROLE` configuration, loading failure issue
|
||||
- feat: Worker adds `# ADDRESS_REGEX = "[^a-z.0-9]"` configuration, regex for replacing illegal symbols, if not set, defaults to [^a-z0-9], use with caution, some symbols may cause receiving issues
|
||||
- feat: Worker optimizes webhook logic, supports admin configuring global webhook, adds `message pusher` integration example
|
||||
|
||||
## v0.7.1
|
||||
|
||||
- fix: Fix user role loading failure issue
|
||||
- feat: Admin account settings adds source email address blacklist configuration
|
||||
|
||||
## v0.7.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: Add user `passkey` table, need to execute `db/2024-08-10-patch.sql` to update `D1` database
|
||||
|
||||
### Changes
|
||||
|
||||
- Docs: Update new-address-api.md (#360)
|
||||
- feat: Worker adds `ADMIN_USER_ROLE` configuration, for configuring admin user role, users with this role can access admin management page (#363)
|
||||
- feat: Worker adds `DISABLE_SHOW_GITHUB` configuration, for configuring whether to show github link
|
||||
- feat: Worker adds `NO_LIMIT_SEND_ROLE` configuration, for configuring roles that can send unlimited emails
|
||||
- feat: User adds `passkey` login method, for user login, no password required
|
||||
- feat: Worker adds `DISABLE_ADMIN_PASSWORD_CHECK` configuration, for configuring whether to disable admin console password check, if your site is only privately accessible, you can disable the check
|
||||
|
||||
## v0.6.1
|
||||
|
||||
- pages github actions && fix cleanup emails days 0 not taking effect by @tqjason (#355)
|
||||
- fix: imap proxy server doesn't support password by @dreamhunter2333 (#356)
|
||||
- worker adds `ANNOUNCEMENT` configuration, for configuring announcement info by @dreamhunter2333 (#357)
|
||||
- fix: telegram bot create new address defaults to first domain by @dreamhunter2333 (#358)
|
||||
|
||||
## v0.6.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
DB changes: Add user role table, need to execute `db/2024-07-14-patch.sql` to update `D1` database
|
||||
|
||||
### Changes
|
||||
|
||||
Worker configuration file adds `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, see documentation [worker configuration](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
|
||||
|
||||
- Remove `apiV1` related code and related database tables
|
||||
- Update `admin/statistics` api, add user statistics info
|
||||
- Update address rules, only allow lowercase+numbers, for historical addresses `lowercase` processing will be performed when querying emails
|
||||
- Add user role feature, `admin` can set user roles (currently can configure domain and prefix for each role)
|
||||
- Admin page search optimization, enter key auto search, input content auto trim
|
||||
|
||||
## v0.5.4
|
||||
|
||||
- Click logo 5 times to enter admin page
|
||||
- Fix 401 cannot redirect to login page (admin and site authentication)
|
||||
|
||||
## v0.5.3
|
||||
|
||||
- Fix some bugs in smtp imap proxy server
|
||||
- Improve user/admin delete inbox/outbox functionality
|
||||
- Admin can delete send permission records
|
||||
- Add Chinese email alias configuration `DOMAIN_LABELS` [documentation](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
|
||||
- Remove `mail channels` related code
|
||||
- github actions adds `FRONTEND_BRANCH` variable to specify deployment branch (#324)
|
||||
|
||||
## v0.5.1
|
||||
|
||||
- Add `mail-parser-wasm-worker` for worker email parsing, [documentation](https://temp-mail-docs.awsl.uk/en/guide/feature/mail_parser_wasm_worker.html)
|
||||
- Add user email length validation configuration `MIN_ADDRESS_LEN` and `MAX_ADDRESS_LEN`
|
||||
- Fix `pages function` not forwarding `telegram` api issue
|
||||
|
||||
## v0.5.0
|
||||
|
||||
- UI: Add local cache for address management
|
||||
- worker: Add `FORWARD_ADDRESS_LIST` global email forwarding address (equivalent to `catch all`)
|
||||
- UI: Multi-language uses routing for switching
|
||||
- Add save attachments to S3 feature
|
||||
- UI: Add received email list `batch delete` and `batch download`
|
||||
|
||||
## v0.4.6
|
||||
|
||||
- Worker configuration file adds `TITLE = "Custom Title"`, can customize website title
|
||||
- Fix KV not bound unable to delete address issue
|
||||
|
||||
## v0.4.5
|
||||
|
||||
- UI lazy load
|
||||
- telegram bot adds user global push feature (admin users)
|
||||
- Add support for cloudflare verified user sending emails
|
||||
- Add using `resend` to send emails, `resend` provides http and smtp api, easier to use, documentation: https://temp-mail-docs.awsl.uk/en/guide/config-send-mail.html
|
||||
|
||||
## v0.4.4
|
||||
|
||||
- Add telegram mini app
|
||||
- telegram bot adds `unbind`, `delete` commands
|
||||
- Fix webhook multiline text issue
|
||||
|
||||
## v0.4.3
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Configuration file `main = "src/worker.js"` changed to `main = "src/worker.ts"`
|
||||
|
||||
### Changes
|
||||
|
||||
- `telegram bot` whitelist configuration
|
||||
- `ENABLE_WEBHOOK` add webhook
|
||||
- UI: admin page uses two-level tabs
|
||||
- UI: can directly switch addresses on homepage after login
|
||||
- UI: outbox also uses split view display (similar to inbox)
|
||||
- `SMTP IMAP Proxy` add outbox viewing
|
||||
|
||||
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
|
||||
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
|
||||
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
|
||||
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
|
||||
|
||||
## v0.4.2
|
||||
|
||||
- Fix some bugs in smtp imap proxy server
|
||||
- Fix UI interface text errors, interface adds version number
|
||||
- Add telegram bot documentation https://temp-mail-docs.awsl.uk/en/guide/feature/telegram.html
|
||||
|
||||
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
|
||||
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
|
||||
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
|
||||
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
|
||||
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
|
||||
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
|
||||
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
|
||||
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
|
||||
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
|
||||
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
|
||||
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
|
||||
|
||||
### v0.4.1
|
||||
|
||||
- Username limited to max 30 characters
|
||||
- Fix `/external/api/send_mail` not returning bug (#222)
|
||||
- Add `IMAP proxy` service, support `IMAP` viewing emails
|
||||
- UI interface adds version number display
|
||||
|
||||
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
|
||||
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
|
||||
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
|
||||
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
|
||||
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
|
||||
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
|
||||
|
||||
### New Contributors
|
||||
|
||||
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
|
||||
|
||||
## v0.4.0
|
||||
|
||||
### DB Changes/Breaking changes
|
||||
|
||||
Added user related tables for storing user information
|
||||
|
||||
- `db/2024-05-08-patch.sql`
|
||||
|
||||
### config changes
|
||||
|
||||
Enable user registration email verification requires `KV`
|
||||
|
||||
```toml
|
||||
# kv config for send email verification code
|
||||
# [[kv_namespaces]]
|
||||
# binding = "KV"
|
||||
# id = "xxxx"
|
||||
```
|
||||
|
||||
### function changes
|
||||
|
||||
- Add user registration feature, can bind email addresses, automatically obtain email JWT credentials after binding
|
||||
- Add default text display for emails, text and HTML email display mode switch button
|
||||
- Fix `BUG` randomly generated email names are invalid #211
|
||||
- `admin` email page supports email content search #210
|
||||
- Fix bug where emails weren't deleted when deleting addresses #213
|
||||
- UI adds global tab position configuration, side margin configuration
|
||||
|
||||
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
|
||||
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
|
||||
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
|
||||
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
|
||||
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
|
||||
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
|
||||
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
|
||||
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
|
||||
|
||||
## v0.3.3
|
||||
|
||||
- Fix Admin delete email error
|
||||
- UI: Reply email button, quote original email text #186
|
||||
- Add send email address blacklist
|
||||
- Add `CF Turnstile` CAPTCHA configuration
|
||||
- Add `/external/api/send_mail` send email api, use body verification #194
|
||||
|
||||
## v0.3.2
|
||||
|
||||
## What's Changed
|
||||
|
||||
- UI: Add reply email button
|
||||
- Add scheduled cleanup feature, configurable in admin page (need to enable scheduled task in config file)
|
||||
- Fix delete account no response issue
|
||||
|
||||
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
|
||||
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
|
||||
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
|
||||
|
||||
## v0.3.1
|
||||
|
||||
### DB Changes
|
||||
|
||||
Added `settings` table for storing general configuration information
|
||||
|
||||
- `db/2024-05-01-patch.sql`
|
||||
|
||||
### Changes
|
||||
|
||||
- `ENABLE_USER_CREATE_EMAIL` whether to allow users to create emails
|
||||
- Allow admin to create emails without prefix
|
||||
- Add `SMTP proxy server`, support SMTP sending emails
|
||||
- Fix some cases where browsers can't load `wasm` use js to parse emails
|
||||
- Footer adds `COPYRIGHT`
|
||||
- UI allows users to switch email display mode `v-html` / `iframe`
|
||||
- Add `admin` account configuration page, support configuring user registration name blacklist
|
||||
|
||||
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
|
||||
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
|
||||
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
|
||||
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
|
||||
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
|
||||
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
|
||||
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
|
||||
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
|
||||
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
|
||||
|
||||
## v0.3.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
The prefix of the `address` table will migrate from code to db, please replace `tmp` in the sql below with your prefix, then execute.
|
||||
If your data is important, please backup your database first.
|
||||
|
||||
**Note: Replace prefix**
|
||||
|
||||
```sql
|
||||
update
|
||||
address
|
||||
set
|
||||
name = 'tmp' || name;
|
||||
```
|
||||
|
||||
### Changes
|
||||
|
||||
- Migrate the prefix of the `address` table from code to db
|
||||
- `admin` account page adds send/receive email counts
|
||||
- `admin` outbox page defaults to show all
|
||||
- `admin` send permission page supports search by address
|
||||
- `admin` email page uses split view UI
|
||||
|
||||
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
|
||||
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
|
||||
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
|
||||
|
||||
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
|
||||
|
||||
## v0.2.10
|
||||
|
||||
- `ENABLE_USER_DELETE_EMAIL` whether to allow users to delete account and emails
|
||||
- `ENABLE_AUTO_REPLY` whether to enable auto reply
|
||||
- fetchAddressError prompt improvement
|
||||
- Auto refresh shows countdown
|
||||
|
||||
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
|
||||
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
|
||||
|
||||
## v0.2.9
|
||||
|
||||
- Add rich text editor
|
||||
- Admin contact info, won't show if not configured, can configure any string `ADMIN_CONTACT = "xx@xx.xxx"`
|
||||
- Default send email balance, if not set, will be 0 `DEFAULT_SEND_BALANCE = 1`
|
||||
|
||||
## v0.2.8
|
||||
|
||||
- Allow users to delete emails
|
||||
- Admin notifies user by email when modifying send permissions
|
||||
- Send permission defaults to 1
|
||||
- Add RATE_LIMITER rate limiting for sending emails and creating new addresses
|
||||
- Some bug fixes
|
||||
|
||||
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
|
||||
- feat: request_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
|
||||
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
|
||||
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
|
||||
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
|
||||
|
||||
## v0.2.7
|
||||
|
||||
- Added user interface installation documentation
|
||||
- Support email DKIM
|
||||
- Rate limiting configuration for `/api/new_address`
|
||||
|
||||
## v0.2.6
|
||||
|
||||
- Added admin query outbox page
|
||||
- Add admin data cleaning page
|
||||
|
||||
## 2024-04-12 v0.2.5
|
||||
|
||||
- Support send email
|
||||
|
||||
DB changes:
|
||||
|
||||
- `db/2024-04-12-patch.sql`
|
||||
|
||||
## 2024-04-10 v0.2.0
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- remove `ENABLE_ATTACHMENT` config
|
||||
- use rust wasm to parse email in frontend
|
||||
- deprecated api moved to `/api/v1`
|
||||
|
||||
### Rust Mail Parser
|
||||
|
||||
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
|
||||
|
||||
- Faster speed, good attachment support, can display attachment images of emails
|
||||
- Parsing supports more rfc specifications
|
||||
|
||||
### DB changes
|
||||
|
||||
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
|
||||
|
||||
## Upgrade Step
|
||||
|
||||
```bash
|
||||
git checkout v0.2.0
|
||||
cd worker
|
||||
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
|
||||
pnpm run deploy
|
||||
cd ../frontend
|
||||
pnpm run deploy
|
||||
```
|
||||
|
||||
Note: For historical messages, use the Deploy New web page to view old data.
|
||||
|
||||
```bash
|
||||
git checkout feature/backup
|
||||
cd frontend
|
||||
# Create a new pages for accessing old data
|
||||
pnpm run deploy --project-name temp-email-v1
|
||||
```
|
||||
|
||||
## 2024-04-09 v0.0.0
|
||||
|
||||
release v0.0.0
|
||||
|
||||
## 2024-04-03
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-04-03-patch.sql`
|
||||
|
||||
Changes:
|
||||
|
||||
- add delete account
|
||||
- add admin panel search
|
||||
|
||||
## 2024-01-13
|
||||
|
||||
DB changes
|
||||
|
||||
- `db/2024-01-13-patch.sql`
|
||||
103
CLAUDE.md
Normal file
103
CLAUDE.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 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).
|
||||
- **E2E tests**: `e2e/` — Playwright tests in Docker Compose (API, browser, SMTP proxy).
|
||||
- **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`.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
Tests run in Docker Compose with Playwright. From `e2e/`:
|
||||
|
||||
```bash
|
||||
npm test # Build, run all tests, exit
|
||||
npm run test:down # Clean up containers
|
||||
```
|
||||
|
||||
Test categories: `tests/api/` (API tests), `tests/browser/` (UI tests with Chromium), `tests/smtp-proxy/` (SMTP/IMAP proxy tests).
|
||||
|
||||
The Docker frontend serves over **HTTPS** (self-signed cert) with Vite proxy to worker — required for WebAuthn (`navigator.credentials`) and `crypto.subtle` which need a secure context. Browser tests use `ignoreHTTPSErrors: true`.
|
||||
|
||||
Key patterns for browser tests:
|
||||
- Frontend hashes passwords with SHA-256 (`crypto.subtle`) before sending — API test registration must use pre-hashed passwords if UI login is needed.
|
||||
- VueUse `useStorage('key', '')` with string default uses **raw string** serialization — set localStorage with raw value, not `JSON.stringify()`.
|
||||
- WebAuthn browser tests use CDP virtual authenticator (`WebAuthn.enable` + `WebAuthn.addVirtualAuthenticator`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Worker Auth Flow (`worker/src/worker.ts`)
|
||||
|
||||
Three auth layers applied via Hono middleware, each using different headers:
|
||||
|
||||
| Path prefix | Header | Purpose |
|
||||
|-------------|--------|---------|
|
||||
| `/api/*` | `Authorization: Bearer <jwt>` | Address (mailbox) credential |
|
||||
| `/user_api/*` | `x-user-token` | User account JWT |
|
||||
| `/admin/*` | `x-admin-auth` | Admin password |
|
||||
| (any) | `x-user-access-token` | User role-based access token |
|
||||
| (any) | `x-custom-auth` | Optional global access password |
|
||||
| (any) | `x-lang` | Language preference (`en`/`zh`) |
|
||||
|
||||
Public endpoints (no auth): `/open_api/*`, `/user_api/login`, `/user_api/register`, `/user_api/passkey/authenticate_*`, `/user_api/oauth2/*`.
|
||||
|
||||
### Worker Email Flow (`worker/src/email/`)
|
||||
|
||||
Cloudflare Email Worker entry: `email()` in `worker/src/email/index.ts`. Processing pipeline:
|
||||
1. Parse raw email → check junk → check address exists
|
||||
2. Auto-reply if configured → forward if configured → webhook if enabled
|
||||
3. Store in D1 database
|
||||
|
||||
### Frontend State (`frontend/src/store/index.js`, `frontend/src/api/index.js`)
|
||||
|
||||
Global state via VueUse `useStorage` for persistence. The `api` module wraps axios with auto-attached auth headers and fingerprinting. API base URL comes from `VITE_API_BASE` env var (empty = same origin).
|
||||
|
||||
## Coding Style
|
||||
|
||||
- `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
|
||||
- Keep existing naming patterns: `*_api/` folders, `utils/`, `models/`.
|
||||
- ESM imports only (`type: module`).
|
||||
|
||||
## 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.
|
||||
|
||||
## Config
|
||||
|
||||
- Worker settings in `worker/wrangler.toml` (see `wrangler.toml.template` for bindings).
|
||||
- Frontend uses `VITE_*` env vars. Don't commit secrets.
|
||||
215
README.md
215
README.md
@@ -1,89 +1,198 @@
|
||||
# 使用 cloudflare 免费服务,搭建临时邮箱
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare 临时邮箱 - 免费搭建临时邮件服务
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub"/>
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?style=for-the-badge&logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email?style=for-the-badge">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a>
|
||||
</p>
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
|
||||
## [查看部署文档](https://temp-mail-docs.awsl.uk)
|
||||
**一个功能完整的临时邮箱服务!**
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/dreamhunter2333/cloudflare_temp_email)
|
||||
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||
- **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||
- **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
|
||||
|
||||
[Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/github-action.html)
|
||||
## 部署文档 - 快速开始
|
||||
|
||||
[English Docs](https://temp-mail-docs.awsl.uk/en/)
|
||||
[部署文档](https://temp-mail-docs.awsl.uk) | [Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
|
||||
|
||||
## [CHANGELOG](CHANGELOG.md)
|
||||
<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>
|
||||
|
||||
## [在线演示](https://mail.awsl.uk/)
|
||||
## 更新日志
|
||||
|
||||
查看 [CHANGELOG](CHANGELOG.md) 了解最新更新内容。
|
||||
|
||||
## 在线体验
|
||||
|
||||
立即体验 → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>服务状态监控(点击收缩/展开)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<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" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
|
||||
- [查看部署文档](#查看部署文档)
|
||||
- [CHANGELOG](#changelog)
|
||||
- [在线演示](#在线演示)
|
||||
- [功能/TODO](#功能todo)
|
||||
- [Reference](#reference)
|
||||
- [Join Community](#join-community)
|
||||
</details>
|
||||
|
||||
## 功能/TODO
|
||||
<details open>
|
||||
<summary>目录(点击收缩/展开)</summary>
|
||||
|
||||
- [x] 使用 `password` 重新登录之前的邮箱
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 支持多语言
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
- [x] 增加自动回复功能
|
||||
- [x] 增加查看 `附件` 功能
|
||||
- [x] 使用 `rust wasm` 解析邮件
|
||||
- [x] 支持发送邮件
|
||||
- [x] 支持 `DKIM`
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件
|
||||
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
|
||||
- [部署文档 - 快速开始](#部署文档---快速开始)
|
||||
- [更新日志](#更新日志)
|
||||
- [在线体验](#在线体验)
|
||||
- [核心功能](#核心功能)
|
||||
- [邮件处理](#邮件处理)
|
||||
- [用户管理](#用户管理)
|
||||
- [管理功能](#管理功能)
|
||||
- [多语言与界面](#多语言与界面)
|
||||
- [集成与扩展](#集成与扩展)
|
||||
- [技术架构](#技术架构)
|
||||
- [系统架构](#系统架构)
|
||||
- [技术栈](#技术栈)
|
||||
- [主要组件](#主要组件)
|
||||
- [加入社区](#加入社区)
|
||||
|
||||
</details>
|
||||
|
||||
## 核心功能
|
||||
|
||||
<details open>
|
||||
<summary>核心功能详情(点击收缩/展开)</summary>
|
||||
|
||||
### 邮件处理
|
||||
|
||||
- [x] 使用 `rust wasm` 解析邮件,解析速度快,几乎所有邮件都能解析,node 的解析模块解析邮件失败的邮件,rust wasm 也能解析成功
|
||||
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
|
||||
- [x] 支持为指定基础域名创建随机二级域名邮箱地址,更适合收件隔离场景
|
||||
- [x] 支持发送邮件,支持 `DKIM` 验证
|
||||
- [x] 支持 `SMTP` 和 `Resend` 等多种发送方式
|
||||
- [x] 增加查看 `附件` 功能,支持附件图片显示
|
||||
- [x] 支持 S3 附件存储和删除功能
|
||||
- [x] 垃圾邮件检测和黑白名单配置
|
||||
- [x] 邮件转发功能,支持全局转发地址
|
||||
|
||||
### 用户管理
|
||||
|
||||
- [x] 使用 `凭证` 重新登录之前的邮箱
|
||||
- [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱
|
||||
- [x] `Telegram Bot` 使用,以及 `Telegram` 推送
|
||||
- [x] 支持 `OAuth2` 第三方登录(Github、Authentik 等)
|
||||
- [x] 支持 `Passkey` 无密码登录
|
||||
- [x] 用户角色管理,支持多角色域名和前缀配置
|
||||
- [x] 用户收件箱查看,支持地址和关键词过滤
|
||||
|
||||
## Reference
|
||||
### 管理功能
|
||||
|
||||
- Cloudflare D1 作为数据库
|
||||
- 使用 Cloudflare Pages 部署前端
|
||||
- 使用 Cloudflare Workers 部署后端
|
||||
- email 转发使用 Cloudflare Email Routing
|
||||
- [x] 完整的 admin 控制台
|
||||
- [x] `admin` 后台创建无前缀邮箱
|
||||
- [x] admin 用户管理页面,增加用户地址查看功能
|
||||
- [x] 定时清理功能,支持多种清理策略
|
||||
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
|
||||
- [x] 增加访问密码,可作为私人站点
|
||||
|
||||
## Join Community
|
||||
### 多语言与界面
|
||||
|
||||
- [x] 前后台均支持多语言
|
||||
- [x] 现代化 UI 设计,支持响应式布局
|
||||
- [x] 支持 Google Ads 集成
|
||||
- [x] 使用 shadow DOM 防止样式污染
|
||||
- [x] 支持 URL JWT 参数自动登录
|
||||
|
||||
### 集成与扩展
|
||||
|
||||
- [x] 完整的 `Telegram Bot` 支持,以及 `Telegram` 推送,Telegram Bot 小程序
|
||||
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件,`IMAP` 查看邮件
|
||||
- [x] Webhook 支持,消息推送集成
|
||||
- [x] 支持 `CF Turnstile` 人机验证
|
||||
- [x] 限流配置,防止滥用
|
||||
|
||||
</details>
|
||||
|
||||
## 技术架构
|
||||
|
||||
<details>
|
||||
<summary>技术架构详情(点击收缩/展开)</summary>
|
||||
|
||||
### 系统架构
|
||||
|
||||
- **数据库**: Cloudflare D1 作为主数据库
|
||||
- **前端部署**: 使用 Cloudflare Pages 部署前端
|
||||
- **后端部署**: 使用 Cloudflare Workers 部署后端
|
||||
- **邮件转发**: 使用 Cloudflare Email Routing
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **前端**: Vue 3 + Vite + TypeScript
|
||||
- **后端**: TypeScript + Cloudflare Workers
|
||||
- **邮件解析**: Rust WASM (mail-parser-wasm)
|
||||
- **数据库**: Cloudflare D1 (SQLite)
|
||||
- **存储**: Cloudflare KV + R2 (可选 S3)
|
||||
- **代理服务**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 主要组件
|
||||
|
||||
- **Worker**: 核心后端服务
|
||||
- **Frontend**: Vue 3 用户界面
|
||||
- **Mail Parser WASM**: Rust 邮件解析模块
|
||||
- **SMTP Proxy Server**: Python 邮件代理服务
|
||||
- **Pages Functions**: Cloudflare Pages 中间件
|
||||
- **Documentation**: VitePress 文档站点
|
||||
|
||||
</details>
|
||||
|
||||
### 提醒
|
||||
|
||||
- 在Resend添加域名记录时,如果您域名解析服务商正在托管您的3级域名a.b.com,请删除Resend生成的默认name中二级域名前缀b,否则将会添加a.b.b.com,导致验证失败。添加记录后,可通过
|
||||
```bash
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
进行验证。
|
||||
|
||||
## 加入社区
|
||||
|
||||
- [Discord](https://discord.gg/dQEwTWhA6Q)
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
|
||||
197
README_EN.md
Normal file
197
README_EN.md
Normal file
@@ -0,0 +1,197 @@
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare Temp Email - Free Temporary Email Service
|
||||
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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!**
|
||||
|
||||
- **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
|
||||
|
||||
[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
|
||||
|
||||
See [CHANGELOG](CHANGELOG.md) for the latest updates.
|
||||
|
||||
## Live Demo
|
||||
|
||||
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>Service Status Monitoring (Click to expand/collapse)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<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" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<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)
|
||||
|
||||
</details>
|
||||
|
||||
## Core Features
|
||||
|
||||
<details open>
|
||||
<summary>Core Features Details (Click to expand/collapse)</summary>
|
||||
|
||||
### 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
|
||||
- [x] Support optional random second-level subdomain mailbox creation for selected base domains
|
||||
- [x] Support sending emails with `DKIM` verification
|
||||
- [x] Support multiple sending methods such as `SMTP` and `Resend`
|
||||
- [x] Add attachment viewing feature with support for displaying attachment images
|
||||
- [x] Support S3 attachment storage and deletion
|
||||
- [x] Spam detection and blacklist/whitelist configuration
|
||||
- [x] Email forwarding feature with global forwarding address support
|
||||
|
||||
### 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
|
||||
- [x] Support `OAuth2` third-party login (Github, Authentik, etc.)
|
||||
- [x] Support `Passkey` passwordless login
|
||||
- [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
|
||||
|
||||
- [x] Complete admin console
|
||||
- [x] Create mailboxes without prefix in `admin` backend
|
||||
- [x] Admin user management page with user address viewing feature
|
||||
- [x] Scheduled cleanup function with support for multiple cleanup strategies
|
||||
- [x] Get mailboxes with custom names, `admin` can configure blacklist
|
||||
- [x] Add access password for use as a private site
|
||||
|
||||
### Multi-language & Interface
|
||||
|
||||
- [x] Both frontend and backend support multi-language
|
||||
- [x] Modern UI design with responsive layout
|
||||
- [x] Google Ads integration support
|
||||
- [x] Use shadow DOM to prevent style pollution
|
||||
- [x] Support URL JWT parameter auto-login
|
||||
|
||||
### 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
|
||||
- [x] Webhook support and message push integration
|
||||
- [x] Support `CF Turnstile` CAPTCHA verification
|
||||
- [x] Rate limiting configuration to prevent abuse
|
||||
|
||||
</details>
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
<details>
|
||||
<summary>Technical Architecture Details (Click to expand/collapse)</summary>
|
||||
|
||||
### 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
|
||||
|
||||
- **Frontend**: Vue 3 + Vite + TypeScript
|
||||
- **Backend**: TypeScript + Cloudflare Workers
|
||||
- **Email Parsing**: Rust WASM (mail-parser-wasm)
|
||||
- **Database**: Cloudflare D1 (SQLite)
|
||||
- **Storage**: Cloudflare KV + R2 (optional S3)
|
||||
- **Proxy Service**: Python SMTP/IMAP Proxy Server
|
||||
|
||||
### Main Components
|
||||
|
||||
- **Worker**: Core backend service
|
||||
- **Frontend**: Vue 3 user interface
|
||||
- **Mail Parser WASM**: Rust email parsing module
|
||||
- **SMTP Proxy Server**: Python email proxy service
|
||||
- **Pages Functions**: Cloudflare Pages middleware
|
||||
- **Documentation**: VitePress documentation site
|
||||
|
||||
</details>
|
||||
|
||||
### Important Notes
|
||||
|
||||
- When adding domain records in Resend, if your DNS provider is hosting your 3rd level domain a.b.com, please remove the 2nd level domain prefix b from the default name generated by Resend, otherwise it will add a.b.b.com, causing verification to fail. After adding the record, you can verify it using:
|
||||
```bash
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
|
||||
## Join the Community
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
4
db/2025-09-23-patch.sql
Normal file
4
db/2025-09-23-patch.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE
|
||||
address
|
||||
ADD
|
||||
password TEXT;
|
||||
4
db/2025-12-06-metadata.sql
Normal file
4
db/2025-12-06-metadata.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add metadata column to raw_mails table for storing AI extraction results and other metadata
|
||||
-- This column stores JSON data with flexible schema for various analysis results
|
||||
|
||||
ALTER TABLE raw_mails ADD COLUMN metadata TEXT;
|
||||
4
db/2025-12-15-message-id-index.sql
Normal file
4
db/2025-12-15-message-id-index.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Add index on message_id column in raw_mails table
|
||||
-- This index improves performance for queries filtering/updating by message_id
|
||||
-- Example: UPDATE raw_mails SET metadata = ? WHERE message_id = ?
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
8
db/2025-12-27-source-meta.sql
Normal file
8
db/2025-12-27-source-meta.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Add source_meta column to address table for tracking address creation source
|
||||
-- For web: stores IP address (e.g., "192.168.1.1") or "web:unknown" as fallback
|
||||
-- For telegram: stores "tg:{userId}" (e.g., "tg:123456789")
|
||||
-- For admin: stores "admin"
|
||||
|
||||
ALTER TABLE address ADD COLUMN source_meta TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
|
||||
2
db/2026-04-03-raw-blob.sql
Normal file
2
db/2026-04-03-raw-blob.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add raw_blob BLOB column for gzip-compressed email storage
|
||||
ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;
|
||||
@@ -4,20 +4,34 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
||||
source TEXT,
|
||||
address TEXT,
|
||||
raw TEXT,
|
||||
raw_blob BLOB,
|
||||
metadata TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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,
|
||||
password TEXT,
|
||||
source_meta TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auto_reply_mails (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_prefix TEXT,
|
||||
@@ -50,6 +64,8 @@ CREATE TABLE IF NOT EXISTS sendbox (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
|
||||
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"]
|
||||
47
e2e/Dockerfile.frontend
Normal file
47
e2e/Dockerfile.frontend
Normal file
@@ -0,0 +1,47 @@
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
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/ .
|
||||
|
||||
# Generate self-signed cert for HTTPS (required for WebAuthn/crypto.subtle)
|
||||
RUN openssl req -x509 -newkey rsa:2048 -keyout /tmp/key.pem -out /tmp/cert.pem \
|
||||
-days 365 -nodes -subj '/CN=frontend'
|
||||
|
||||
# Allow Docker internal hostnames (e.g. "frontend") to pass Vite's host check.
|
||||
# Configure HTTPS with self-signed cert for secure context (WebAuthn/crypto.subtle).
|
||||
# Proxy API paths to the worker to avoid mixed-content (HTTPS->HTTP) blocking.
|
||||
RUN mv vite.config.js vite.config.original.js && \
|
||||
echo 'import config from "./vite.config.original.js";\
|
||||
import fs from "fs";\
|
||||
const workerTarget = process.env.VITE_WORKER_URL || "http://worker:8787";\
|
||||
config.server = {\
|
||||
...config.server,\
|
||||
allowedHosts: true,\
|
||||
https: {\
|
||||
key: fs.readFileSync("/tmp/key.pem"),\
|
||||
cert: fs.readFileSync("/tmp/cert.pem"),\
|
||||
},\
|
||||
proxy: {\
|
||||
"/api": { target: workerTarget, changeOrigin: true },\
|
||||
"/admin": { target: workerTarget, changeOrigin: true },\
|
||||
"/user_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/open_api": { target: workerTarget, changeOrigin: true },\
|
||||
"/external": { target: workerTarget, changeOrigin: true },\
|
||||
"/health_check": { target: workerTarget, changeOrigin: true },\
|
||||
},\
|
||||
};\
|
||||
export default config;' > vite.config.js
|
||||
|
||||
# Empty VITE_API_BASE so frontend uses same-origin (proxied through Vite)
|
||||
ENV VITE_API_BASE=
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["pnpm", "exec", "vite", "--port", "5173", "--host", "0.0.0.0"]
|
||||
19
e2e/Dockerfile.worker
Normal file
19
e2e/Dockerfile.worker
Normal file
@@ -0,0 +1,19 @@
|
||||
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 ./
|
||||
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
||||
COPY ${WRANGLER_TOML} 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`)
|
||||
157
e2e/docker-compose.yml
Normal file
157
e2e/docker-compose.yml
Normal file
@@ -0,0 +1,157 @@
|
||||
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
|
||||
|
||||
worker-subdomain:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8789:8789"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8789", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8789/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-env-off:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
ports:
|
||||
- "8790:8790"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
|
||||
volumes:
|
||||
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8790/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
worker-gzip:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: e2e/Dockerfile.worker
|
||||
args:
|
||||
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.gzip
|
||||
ports:
|
||||
- "8788:8788"
|
||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8788", "--ip", "0.0.0.0"]
|
||||
depends_on:
|
||||
- mailpit
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://localhost:8788/health_check"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 20
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ..
|
||||
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
|
||||
WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789
|
||||
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
||||
WORKER_GZIP_URL: http://worker-gzip:8788
|
||||
FRONTEND_URL: https://frontend:5173
|
||||
MAILPIT_API: http://mailpit:8025/api
|
||||
SMTP_PROXY_HOST: smtp-proxy
|
||||
SMTP_PROXY_SMTP_PORT: "8025"
|
||||
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
|
||||
worker-subdomain:
|
||||
condition: service_healthy
|
||||
worker-env-off:
|
||||
condition: service_healthy
|
||||
worker-gzip:
|
||||
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
|
||||
213
e2e/fixtures/test-helpers.ts
Normal file
213
e2e/fixtures/test-helpers.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { APIRequestContext } from '@playwright/test';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export const WORKER_URL = process.env.WORKER_URL!;
|
||||
export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
|
||||
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
||||
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||
export const TEST_DOMAIN = 'test.example.com';
|
||||
|
||||
/**
|
||||
* 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; address_id: number }> {
|
||||
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, address_id: body.address_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()}`);
|
||||
}
|
||||
}
|
||||
33
e2e/fixtures/wrangler.toml.e2e
Normal file
33
e2e/fixtures/wrangler.toml.e2e
Normal file
@@ -0,0 +1,33 @@
|
||||
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
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email"
|
||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
34
e2e/fixtures/wrangler.toml.e2e.env-off
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_env_off"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
|
||||
JWT_SECRET = "e2e-test-secret-key-env-off"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-env-off"
|
||||
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
34
e2e/fixtures/wrangler.toml.e2e.gzip
Normal file
@@ -0,0 +1,34 @@
|
||||
name = "cloudflare_temp_email_gzip"
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2025-04-01"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_GZIP = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "KV"
|
||||
id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "e2e-temp-email-gzip"
|
||||
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
||||
422
e2e/package-lock.json
generated
Normal file
422
e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,422 @@
|
||||
{
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
},
|
||||
"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.3.1",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
|
||||
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
|
||||
"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.8",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.5",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.7"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"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.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"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.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
}
|
||||
}
|
||||
45
e2e/playwright.config.ts
Normal file
45
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const WORKER_BASE = process.env.WORKER_URL!;
|
||||
const WORKER_GZIP_BASE = process.env.WORKER_GZIP_URL || '';
|
||||
const FRONTEND_BASE = process.env.FRONTEND_URL!;
|
||||
|
||||
export default defineConfig({
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
reporter: [['html', { open: 'never' }]],
|
||||
projects: [
|
||||
{
|
||||
name: 'api',
|
||||
testDir: './tests/api',
|
||||
use: {
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'api-gzip',
|
||||
testDir: './tests/api-gzip',
|
||||
use: {
|
||||
baseURL: WORKER_GZIP_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'smtp-proxy',
|
||||
testDir: './tests/smtp-proxy',
|
||||
use: {
|
||||
baseURL: WORKER_BASE,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser',
|
||||
testDir: './tests/browser',
|
||||
use: {
|
||||
baseURL: FRONTEND_BASE,
|
||||
...devices['Desktop Chrome'],
|
||||
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
114
e2e/scripts/docker-entrypoint.sh
Executable file
114
e2e/scripts/docker-entrypoint.sh
Executable file
@@ -0,0 +1,114 @@
|
||||
#!/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
|
||||
|
||||
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||
echo "==> Waiting for subdomain worker at $WORKER_URL_SUBDOMAIN ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL_SUBDOMAIN/health_check" > /dev/null 2>&1; then
|
||||
echo " Subdomain worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Subdomain worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||
echo "==> Waiting for env-off worker at $WORKER_URL_ENV_OFF ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_URL_ENV_OFF/health_check" > /dev/null 2>&1; then
|
||||
echo " Env-off worker ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Env-off worker not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
echo "==> Waiting for worker-gzip at $WORKER_GZIP_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf "$WORKER_GZIP_URL/health_check" > /dev/null 2>&1; then
|
||||
echo " Worker-gzip ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "ERROR: Worker-gzip not ready after 60s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -skf "$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"
|
||||
|
||||
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
|
||||
echo "==> Initializing subdomain worker database"
|
||||
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_migration" > /dev/null
|
||||
echo " Subdomain worker database initialized"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
|
||||
echo "==> Initializing env-off worker database"
|
||||
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_migration" > /dev/null
|
||||
echo " Env-off database initialized"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
||||
echo "==> Initializing gzip worker database"
|
||||
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_initialize" > /dev/null
|
||||
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_migration" > /dev/null
|
||||
echo " Gzip worker database initialized"
|
||||
fi
|
||||
|
||||
echo "==> Running Playwright tests"
|
||||
exec npx playwright test "$@"
|
||||
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
|
||||
242
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
242
e2e/tests/api-gzip/mail-gzip.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_GZIP_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
/**
|
||||
* These tests run against a worker instance with ENABLE_MAIL_GZIP=true.
|
||||
* They verify gzip-compressed storage and backward-compatible reading.
|
||||
*/
|
||||
|
||||
// Helper: create address on the gzip worker
|
||||
async function createGzipAddress(ctx: any, name: string) {
|
||||
const uniqueName = `${name}${Date.now()}`;
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/api/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
|
||||
}
|
||||
|
||||
// Helper: seed mail via receiveMail (goes through email() handler → gzip compression)
|
||||
async function receiveGzipMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; html?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const boundary = `----E2E${Date.now()}`;
|
||||
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
|
||||
const textPart = opts.text || 'Hello from E2E';
|
||||
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
``,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
textPart,
|
||||
`--${boundary}`,
|
||||
`Content-Type: text/html; charset=utf-8`,
|
||||
``,
|
||||
htmlPart,
|
||||
`--${boundary}--`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||
const body = await res.json();
|
||||
if (!body.success) throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
|
||||
}
|
||||
|
||||
// Helper: seed mail via seedMail (direct INSERT → plaintext raw, no gzip)
|
||||
async function seedPlaintextMail(
|
||||
ctx: any, address: string,
|
||||
opts: { subject?: string; text?: string; from?: string }
|
||||
) {
|
||||
const from = opts.from || `sender@${TEST_DOMAIN}`;
|
||||
const subject = opts.subject || 'Plaintext Mail';
|
||||
const messageId = `<e2e-plain-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
opts.text || 'Hello plaintext from E2E',
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
|
||||
data: { address, source: from, raw, message_id: messageId },
|
||||
});
|
||||
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Helper: delete address on gzip worker
|
||||
async function deleteGzipAddress(ctx: any, jwt: string) {
|
||||
await ctx.delete(`${WORKER_GZIP_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Mail Gzip Storage', () => {
|
||||
test.beforeEach(() => {
|
||||
test.skip(!WORKER_GZIP_URL, 'WORKER_GZIP_URL not set — skipping gzip tests');
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-list');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip List Test',
|
||||
text: 'compressed content here',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].raw).toContain('Gzip List Test');
|
||||
expect(results[0].raw).toContain('compressed content here');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable in detail', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-detail');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip Detail Test',
|
||||
html: '<b>bold gzip</b>',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${mailId}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.raw).toContain('Gzip Detail Test');
|
||||
expect(mail.raw).toContain('<b>bold gzip</b>');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
|
||||
try {
|
||||
// 1. Direct INSERT plaintext (simulates pre-gzip data)
|
||||
await seedPlaintextMail(request, address, {
|
||||
subject: 'Old Plaintext Mail',
|
||||
text: 'legacy plain content',
|
||||
});
|
||||
|
||||
// 2. receiveMail → goes through email() handler → gzip compressed
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'New Gzip Mail',
|
||||
text: 'new compressed content',
|
||||
});
|
||||
|
||||
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const { results } = await res.json();
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
// Both mails should have readable raw content
|
||||
const subjects = results.map((r: any) => r.raw);
|
||||
expect(subjects.some((r: string) => r.includes('Old Plaintext Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('New Gzip Mail'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('legacy plain content'))).toBe(true);
|
||||
expect(subjects.some((r: string) => r.includes('new compressed content'))).toBe(true);
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin internal mail (sendAdminInternalMail) is gzip-compressed and readable', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-mail');
|
||||
try {
|
||||
// 1. Request send access → creates address_sender row
|
||||
const reqAccessRes = await request.post(`${WORKER_GZIP_URL}/api/request_send_mail_access`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(reqAccessRes.ok()).toBe(true);
|
||||
|
||||
// 2. Get address_sender id
|
||||
const senderListRes = await request.get(
|
||||
`${WORKER_GZIP_URL}/admin/address_sender?limit=10&offset=0&address=${encodeURIComponent(address)}`,
|
||||
);
|
||||
expect(senderListRes.ok()).toBe(true);
|
||||
const senderList = await senderListRes.json();
|
||||
expect(senderList.results.length).toBeGreaterThanOrEqual(1);
|
||||
const senderId = senderList.results[0].id;
|
||||
|
||||
// 3. Update send access via admin API → triggers sendAdminInternalMail
|
||||
const updateRes = await request.post(`${WORKER_GZIP_URL}/admin/address_sender`, {
|
||||
data: { address, address_id: senderId, balance: 99, enabled: true },
|
||||
});
|
||||
expect(updateRes.ok()).toBe(true);
|
||||
|
||||
// 4. Verify the internal mail is readable
|
||||
const mailsRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const { results } = await mailsRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// mimetext base64-encodes the Subject header, so match on body content instead
|
||||
const internalMail = results.find((m: any) => m.raw?.includes('balance: 99'));
|
||||
expect(internalMail).toBeDefined();
|
||||
expect(internalMail.raw).toContain('admin@internal');
|
||||
expect(internalMail.raw).toContain('balance: 99');
|
||||
expect(internalMail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('raw_blob field is not exposed in API response', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-noblob');
|
||||
try {
|
||||
await receiveGzipMail(request, address, { subject: 'No Blob Leak' });
|
||||
|
||||
// Check list response
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0]).not.toHaveProperty('raw_blob');
|
||||
|
||||
// Check detail response
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${results[0].id}`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const mail = await detailRes.json();
|
||||
expect(mail).not.toHaveProperty('raw_blob');
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
32
e2e/tests/api/address-lifecycle.spec.ts
Normal file
32
e2e/tests/api/address-lifecycle.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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, address_id } = await createTestAddress(request, 'lifecycle-test');
|
||||
expect(address).toContain('@' + TEST_DOMAIN);
|
||||
expect(jwt).toBeTruthy();
|
||||
expect(address_id).toBeGreaterThan(0);
|
||||
|
||||
// Request send access (creates address_sender row with DEFAULT_SEND_BALANCE)
|
||||
await requestSendAccess(request, jwt);
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
48
e2e/tests/api/admin-address-query.spec.ts
Normal file
48
e2e/tests/api/admin-address-query.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN, createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
// Regression tests for #956: long admin search queries must not trigger
|
||||
// D1's "LIKE or GLOB pattern too complex" error.
|
||||
test.describe('Admin Address Query (#956)', () => {
|
||||
test('short query (subdomain fragment) returns matching address via LIKE', async ({ request }) => {
|
||||
const created = await createTestAddress(request, 'q956short');
|
||||
const fragment = created.address.split('@')[0].slice(0, 8);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { limit: '20', offset: '0', query: fragment },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
const names: string[] = body.results.map((r: any) => r.name);
|
||||
expect(names).toContain(created.address);
|
||||
});
|
||||
|
||||
test('long query (>50-byte pattern) does not crash with D1 LIKE error', async ({ request }) => {
|
||||
const longQuery = 'a48r893s@5hx7zb.nationalgeographic.algomindtrade.com';
|
||||
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { limit: '20', offset: '0', query: longQuery },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length).toBe(0);
|
||||
expect(body.count).toBe(0);
|
||||
});
|
||||
|
||||
test('long query also works for /admin/users', async ({ request }) => {
|
||||
const longQuery = 'no-such-user-' + 'x'.repeat(40) + `@${TEST_DOMAIN}`;
|
||||
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
|
||||
|
||||
const res = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: '20', offset: '0', query: longQuery },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body.results)).toBe(true);
|
||||
expect(body.results.length).toBe(0);
|
||||
expect(body.count).toBe(0);
|
||||
});
|
||||
});
|
||||
19
e2e/tests/api/admin-new-address.spec.ts
Normal file
19
e2e/tests/api/admin-new-address.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Admin New Address', () => {
|
||||
test('should return address_id in response', async ({ request }) => {
|
||||
const uniqueName = `admin-test${Date.now()}`;
|
||||
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toContain('@' + TEST_DOMAIN);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
expect(typeof body.address_id).toBe('number');
|
||||
});
|
||||
});
|
||||
185
e2e/tests/api/auto-reply-trigger.spec.ts
Normal file
185
e2e/tests/api/auto-reply-trigger.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Auto Reply Trigger (#459)', () => {
|
||||
/**
|
||||
* Bug #459: source_prefix empty string causes auto-reply to never trigger.
|
||||
* The old condition `results.source_prefix && ...` short-circuits when
|
||||
* source_prefix is "" (falsy). Fix: empty source_prefix should match all.
|
||||
*/
|
||||
test('empty source_prefix triggers auto-reply for any sender', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-trigger');
|
||||
|
||||
try {
|
||||
// Configure auto-reply with empty source_prefix (match all senders)
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Auto Bot',
|
||||
subject: 'Auto Reply',
|
||||
source_prefix: '',
|
||||
message: 'Thanks for your email!',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Send a mail to the address — should trigger auto-reply
|
||||
const receiveRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'anyone@other.com',
|
||||
subject: 'Hello',
|
||||
text: 'Test message',
|
||||
});
|
||||
expect(receiveRes.success).toBe(true);
|
||||
expect(receiveRes.replyCalled).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_prefix startsWith still works (backward compat)', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-prefix');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Prefix Bot',
|
||||
subject: 'Prefix Reply',
|
||||
source_prefix: 'vip@',
|
||||
message: 'VIP auto-reply',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Matching sender — should trigger
|
||||
const matchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'vip@example.com',
|
||||
subject: 'VIP mail',
|
||||
});
|
||||
expect(matchRes.replyCalled).toBe(true);
|
||||
|
||||
// Non-matching sender — should NOT trigger
|
||||
const noMatchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'random@example.com',
|
||||
subject: 'Random mail',
|
||||
});
|
||||
expect(noMatchRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('source_prefix regex match', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-regex');
|
||||
|
||||
try {
|
||||
// Configure regex: match senders from example.com or example.org
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Regex Bot',
|
||||
subject: 'Regex Reply',
|
||||
source_prefix: '/@example\\.(com|org)$/',
|
||||
message: 'Regex auto-reply',
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Matching sender
|
||||
const matchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@example.com',
|
||||
subject: 'Match test',
|
||||
});
|
||||
expect(matchRes.replyCalled).toBe(true);
|
||||
|
||||
// Another matching sender
|
||||
const matchRes2 = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@example.org',
|
||||
subject: 'Match test 2',
|
||||
});
|
||||
expect(matchRes2.replyCalled).toBe(true);
|
||||
|
||||
// Non-matching sender
|
||||
const noMatchRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'user@other.com',
|
||||
subject: 'No match test',
|
||||
});
|
||||
expect(noMatchRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled auto-reply does not trigger', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'auto-reply-disabled');
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: {
|
||||
auto_reply: {
|
||||
name: 'Disabled Bot',
|
||||
subject: 'Should not reply',
|
||||
source_prefix: '',
|
||||
message: 'This should never be sent',
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const receiveRes = await seedTestMailWithReply(request, address, {
|
||||
from: 'anyone@other.com',
|
||||
subject: 'Test disabled',
|
||||
});
|
||||
expect(receiveRes.success).toBe(true);
|
||||
expect(receiveRes.replyCalled).toBe(false);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Send a mail via receive_mail endpoint and return the response
|
||||
* including replyCalled field.
|
||||
*/
|
||||
async function seedTestMailWithReply(
|
||||
ctx: APIRequestContext,
|
||||
address: string,
|
||||
opts: { from?: string; subject?: string; text?: string }
|
||||
): Promise<{ success: boolean; replyCalled: boolean }> {
|
||||
const from = opts.from || 'sender@test.example.com';
|
||||
const subject = opts.subject || 'Test Email';
|
||||
const text = opts.text || 'Hello from E2E';
|
||||
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
|
||||
|
||||
const raw = [
|
||||
`From: ${from}`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
text,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
274
e2e/tests/api/ip-whitelist.spec.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
|
||||
const RESET_SETTINGS = {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
};
|
||||
|
||||
test.describe('IP Whitelist Settings', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: RESET_SETTINGS,
|
||||
});
|
||||
});
|
||||
|
||||
test('get default IP whitelist settings returns disabled with empty list', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enableWhitelist).toBeFalsy();
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('save and retrieve IP whitelist settings', async ({ request }) => {
|
||||
// Save whitelist settings
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4', '^192\\.168\\.1\\.\\d+$'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
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}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(true);
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '^192\\.168\\.1\\.\\d+$']);
|
||||
});
|
||||
|
||||
test('whitelist rejects empty list when enabled', async ({ request }) => {
|
||||
// Note: Frontend blocks this, but backend allows it (empty list = ignored)
|
||||
// This test verifies backend behavior
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Backend accepts empty whitelist (it will be ignored at runtime)
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('whitelist validates array type', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: 'not-an-array', // Invalid type
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('whitelist enforces max size limit', async ({ request }) => {
|
||||
const largeList = Array.from({ length: 1001 }, (_, i) => `1.2.3.${i % 256}`);
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: largeList,
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
const body = await saveRes.text();
|
||||
expect(body).toContain('whitelist');
|
||||
expect(body).toContain('1000');
|
||||
});
|
||||
|
||||
test('backward compatibility: old frontend without whitelist fields', async ({ request }) => {
|
||||
// Simulate old frontend that doesn't send enableWhitelist/whitelist
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: true,
|
||||
blacklist: ['10.0.0.1'],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
// enableWhitelist and whitelist omitted
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
// Should succeed with defaults applied
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
// Verify defaults were applied
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
expect(settings.enableWhitelist).toBe(false);
|
||||
expect(settings.whitelist).toEqual([]);
|
||||
});
|
||||
|
||||
test('whitelist sanitizes patterns (trims and removes empty)', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [' 1.2.3.4 ', '', ' ', '5.6.7.8'],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
});
|
||||
expect(getRes.ok()).toBe(true);
|
||||
const settings = await getRes.json();
|
||||
// Empty strings should be filtered out, whitespace trimmed
|
||||
expect(settings.whitelist).toEqual(['1.2.3.4', '5.6.7.8']);
|
||||
});
|
||||
|
||||
test('whitelist rejects invalid regex pattern', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: ['^[1.2.3.4$'] }, // invalid regex
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
expect(await saveRes.text()).toContain('whitelist');
|
||||
});
|
||||
|
||||
test('whitelist rejects non-string elements', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: { ...RESET_SETTINGS, whitelist: [1, null] },
|
||||
});
|
||||
expect(saveRes.ok()).toBe(false);
|
||||
expect(saveRes.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IP Whitelist Runtime Behavior', () => {
|
||||
test('whitelist with empty list allows requests (protection mode)', async ({ request }) => {
|
||||
// Enable whitelist with empty list
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: true,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to create address (rate-limited endpoint)
|
||||
// Should succeed because empty whitelist is ignored
|
||||
const res = await createTestAddress(request, 'whitelist-empty');
|
||||
expect(res.jwt).toBeTruthy();
|
||||
expect(res.address).toBeTruthy();
|
||||
});
|
||||
|
||||
test('whitelist blocks requests when IP does not match whitelist', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enableWhitelist: true,
|
||||
whitelist: ['1.2.3.4'],
|
||||
},
|
||||
});
|
||||
|
||||
// In e2e, cf-connecting-ip is absent → fail-closed → 403
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: `whitelist-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('IP');
|
||||
});
|
||||
|
||||
test('fingerprint blacklist blocks even when cf-connecting-ip is absent', async ({ request }) => {
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
...RESET_SETTINGS,
|
||||
enabled: true,
|
||||
fingerprintBlacklist: ['blocked-fingerprint-123'],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-fingerprint': 'blocked-fingerprint-123' },
|
||||
data: { name: `fp-block-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
expect(await res.text()).toContain('fingerprint');
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
// Reset whitelist to disabled after each test
|
||||
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
|
||||
headers: { 'x-admin-auth': ADMIN_PASSWORD },
|
||||
data: {
|
||||
enabled: false,
|
||||
blacklist: [],
|
||||
asnBlacklist: [],
|
||||
fingerprintBlacklist: [],
|
||||
enableWhitelist: false,
|
||||
whitelist: [],
|
||||
enableDailyLimit: false,
|
||||
dailyRequestLimit: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
132
e2e/tests/api/login-endpoints.spec.ts
Normal file
132
e2e/tests/api/login-endpoints.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* SHA-256 hash matching frontend hashPassword utility.
|
||||
*/
|
||||
function hashPassword(password: string): string {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
|
||||
|
||||
test('settings returns enableGlobalTurnstileCheck as false', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const settings = await res.json();
|
||||
expect(settings.enableGlobalTurnstileCheck).toBe(false);
|
||||
});
|
||||
|
||||
test.describe('/open_api/site_login', () => {
|
||||
test('returns 401 when no PASSWORDS configured', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/site_login`, {
|
||||
data: {
|
||||
password: hashPassword('any-pass'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/open_api/admin_login', () => {
|
||||
test('correct hashed password succeeds', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: hashPassword('e2e-admin-pass'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
test('wrong password returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: hashPassword('wrong-admin'),
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('empty password returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||
data: {
|
||||
password: '',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/open_api/credential_login', () => {
|
||||
test('valid JWT credential succeeds', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'cred-login');
|
||||
try {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: jwt,
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid JWT returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: 'invalid.jwt.token',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('empty credential returns 401', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||
data: {
|
||||
credential: '',
|
||||
cf_token: ''
|
||||
}
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('/api/address_login with cf_token', () => {
|
||||
test('address login with empty cf_token works when turnstile disabled', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'addr-cf');
|
||||
try {
|
||||
// Set a password
|
||||
await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'addr-pass-123' },
|
||||
});
|
||||
|
||||
// Login with cf_token field present but empty
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: {
|
||||
email: address,
|
||||
password: 'addr-pass-123',
|
||||
cf_token: ''
|
||||
},
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const body = await loginRes.json();
|
||||
expect(body.jwt).toBeTruthy();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
162
e2e/tests/api/passkey.spec.ts
Normal file
162
e2e/tests/api/passkey.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const TEST_USER_EMAIL = `passkey-e2e-${Date.now()}@test.example.com`;
|
||||
const TEST_USER_PASSWORD = 'test-password-123';
|
||||
|
||||
/**
|
||||
* Enable user registration via admin API, register a user, and login to get JWT.
|
||||
*/
|
||||
async function createTestUser(request: APIRequestContext): Promise<string> {
|
||||
// Enable user registration (KV setting)
|
||||
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
},
|
||||
});
|
||||
expect(enableRes.ok()).toBe(true);
|
||||
|
||||
// Register user
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
// Login to get JWT
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
expect(jwt).toBeTruthy();
|
||||
return jwt;
|
||||
}
|
||||
|
||||
test.describe('Passkey API', () => {
|
||||
let userJwt: string;
|
||||
|
||||
test.beforeAll(async ({ request }) => {
|
||||
userJwt = await createTestUser(request);
|
||||
});
|
||||
|
||||
test('register_request returns valid WebAuthn options', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_request`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { domain: 'localhost' },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const options = await res.json();
|
||||
|
||||
// Verify WebAuthn registration options structure
|
||||
expect(options.rp).toBeDefined();
|
||||
expect(options.rp.id).toBe('localhost');
|
||||
expect(options.user).toBeDefined();
|
||||
expect(options.user.name).toBe(TEST_USER_EMAIL);
|
||||
expect(options.challenge).toBeTruthy();
|
||||
expect(options.pubKeyCredParams).toBeInstanceOf(Array);
|
||||
expect(options.pubKeyCredParams.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('authenticate_request returns valid WebAuthn options', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_request`, {
|
||||
data: { domain: 'localhost' },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const options = await res.json();
|
||||
|
||||
// Verify WebAuthn authentication options structure
|
||||
expect(options.challenge).toBeTruthy();
|
||||
expect(options.rpId).toBe('localhost');
|
||||
expect(options.allowCredentials).toBeInstanceOf(Array);
|
||||
});
|
||||
|
||||
test('authenticate_response with invalid credential returns error', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_response`, {
|
||||
data: {
|
||||
domain: 'localhost',
|
||||
origin: 'http://localhost',
|
||||
credential: { id: 'nonexistent-passkey-id' },
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('passkey list is empty for new user', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/user_api/passkey`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const passkeys = await res.json();
|
||||
expect(passkeys).toBeInstanceOf(Array);
|
||||
expect(passkeys.length).toBe(0);
|
||||
});
|
||||
|
||||
test('passkey list remains empty without registration', async ({ request }) => {
|
||||
const listRes = await request.get(`${WORKER_URL}/user_api/passkey`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const passkeys = await listRes.json();
|
||||
expect(passkeys).toBeInstanceOf(Array);
|
||||
expect(passkeys.length).toBe(0);
|
||||
});
|
||||
|
||||
test('register_response with invalid credential returns 400', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_response`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
credential: {
|
||||
id: 'fake-id',
|
||||
rawId: 'fake-raw-id',
|
||||
type: 'public-key',
|
||||
response: {
|
||||
attestationObject: 'invalid-data',
|
||||
clientDataJSON: 'invalid-data',
|
||||
},
|
||||
},
|
||||
origin: 'http://localhost',
|
||||
passkey_name: 'test-passkey',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
// Should fail verification
|
||||
expect(res.status()).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
|
||||
test('rename nonexistent passkey succeeds silently', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
passkey_id: 'nonexistent-id',
|
||||
passkey_name: 'new-name',
|
||||
},
|
||||
});
|
||||
// The SQL UPDATE just affects 0 rows, still returns success
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
test('rename with invalid name returns 400', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: {
|
||||
passkey_id: 'any-id',
|
||||
passkey_name: 'x'.repeat(256),
|
||||
},
|
||||
});
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('delete nonexistent passkey succeeds silently', async ({ request }) => {
|
||||
const res = await request.delete(`${WORKER_URL}/user_api/passkey/nonexistent-id`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
198
e2e/tests/api/subdomain-create.spec.ts
Normal file
198
e2e/tests/api/subdomain-create.spec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
|
||||
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||
|
||||
async function getAccountSettings(request: any, workerUrl: string) {
|
||||
const res = await request.get(`${workerUrl}/admin/account_settings`);
|
||||
expect(res.ok()).toBe(true);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function buildAccountSettingsPayload(
|
||||
current: any,
|
||||
addressCreationSettings?: { enableSubdomainMatch?: boolean | null },
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
blockList: current.blockList || [],
|
||||
sendBlockList: current.sendBlockList || [],
|
||||
verifiedAddressList: current.verifiedAddressList || [],
|
||||
fromBlockList: current.fromBlockList || [],
|
||||
noLimitSendAddressList: current.noLimitSendAddressList || [],
|
||||
emailRuleSettings: current.emailRuleSettings || {},
|
||||
...(typeof addressCreationSettings !== 'undefined'
|
||||
? { addressCreationSettings }
|
||||
: {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
enableSubdomainMatch: boolean | null
|
||||
) {
|
||||
const current = await getAccountSettings(request, workerUrl);
|
||||
const res = await request.post(`${workerUrl}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(current, {
|
||||
enableSubdomainMatch,
|
||||
}),
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function restoreSubdomainMatchSetting(
|
||||
request: any,
|
||||
workerUrl: string,
|
||||
originalValue: boolean | undefined
|
||||
) {
|
||||
if (typeof originalValue === 'boolean') {
|
||||
await saveSubdomainMatchSetting(request, workerUrl, originalValue);
|
||||
return;
|
||||
}
|
||||
await saveSubdomainMatchSetting(request, workerUrl, null);
|
||||
}
|
||||
|
||||
test.describe('Create Address Subdomain Match', () => {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const createAddressSettings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
originalCreateAddressStoredEnabled = createAddressSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
const envOffSettings = await getAccountSettings(request, WORKER_URL_ENV_OFF);
|
||||
originalEnvOffStoredEnabled = envOffSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await restoreSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, originalCreateAddressStoredEnabled);
|
||||
if (WORKER_URL_ENV_OFF) {
|
||||
await restoreSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, originalEnvOffStoredEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin can clear override and return to env fallback', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, null);
|
||||
|
||||
const settings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(settings.addressCreationSubdomainMatchStatus?.storedEnabled).toBeUndefined();
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subenvfb${Date.now()}`, domain: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('invalid addressCreationSettings payload does not partially persist earlier settings', async ({ request }) => {
|
||||
const current = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
const uniqueBlockedKeyword = `should-not-persist-${Date.now()}`;
|
||||
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/account_settings`, {
|
||||
data: buildAccountSettingsPayload(
|
||||
current,
|
||||
{ enableSubdomainMatch: 'invalid-value' as any },
|
||||
{
|
||||
blockList: [...(current.blockList || []), uniqueBlockedKeyword],
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const after = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
|
||||
expect(after.blockList || []).toEqual(current.blockList || []);
|
||||
expect(after.addressCreationSubdomainMatchStatus?.storedEnabled).toBe(
|
||||
current.addressCreationSubdomainMatchStatus?.storedEnabled
|
||||
);
|
||||
});
|
||||
|
||||
test('persisted false still keeps exact match only', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
|
||||
|
||||
const adminName = `subdomain-admin-${Date.now()}`;
|
||||
const adminRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: adminName, domain: SUBDOMAIN },
|
||||
});
|
||||
expect(adminRes.ok()).toBe(true);
|
||||
const adminBody = await adminRes.json();
|
||||
expect(adminBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
expect(adminBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const userName = `subdomain-user-${Date.now()}`;
|
||||
const userRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name: userName, domain: NESTED_SUBDOMAIN },
|
||||
});
|
||||
expect(userRes.ok()).toBe(true);
|
||||
const userBody = await userRes.json();
|
||||
expect(userBody.address).toContain(`@${NESTED_SUBDOMAIN}`);
|
||||
expect(userBody.address_id).toBeGreaterThan(0);
|
||||
|
||||
const mixedCaseRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subcase${Date.now()}`, domain: MIXED_CASE_SUBDOMAIN },
|
||||
});
|
||||
expect(mixedCaseRes.ok()).toBe(true);
|
||||
const mixedCaseBody = await mixedCaseRes.json();
|
||||
expect(mixedCaseBody.address).toContain(`@${SUBDOMAIN}`);
|
||||
|
||||
const invalidRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subinvalid${Date.now()}`, domain: INVALID_LOOKALIKE_DOMAIN },
|
||||
});
|
||||
expect(invalidRes.ok()).toBe(false);
|
||||
expect(await invalidRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyPrefixRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `subempty${Date.now()}`, domain: INVALID_EMPTY_PREFIX_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyPrefixRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyPrefixRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidEmptyLabelRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublabel${Date.now()}`, domain: INVALID_EMPTY_LABEL_DOMAIN },
|
||||
});
|
||||
expect(invalidEmptyLabelRes.ok()).toBe(false);
|
||||
expect(await invalidEmptyLabelRes.text()).toContain('Invalid domain');
|
||||
|
||||
const invalidOverlongRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: `sublong${Date.now()}`, domain: INVALID_OVERLONG_DOMAIN },
|
||||
});
|
||||
expect(invalidOverlongRes.ok()).toBe(false);
|
||||
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
});
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
114
e2e/tests/browser/passkey.spec.ts
Normal file
114
e2e/tests/browser/passkey.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { request as apiRequest } from '@playwright/test';
|
||||
import { createHash } from 'crypto';
|
||||
import { WORKER_URL, FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const TEST_USER_EMAIL = `passkey-browser-${Date.now()}@test.example.com`;
|
||||
const TEST_USER_PASSWORD = 'browser-test-pwd-123';
|
||||
|
||||
// Frontend hashes passwords with SHA-256 before sending to the API.
|
||||
// Register with the hashed password so UI login matches.
|
||||
const HASHED_PASSWORD = createHash('sha256').update(TEST_USER_PASSWORD).digest('hex');
|
||||
|
||||
test.describe('Passkey Browser Flow', () => {
|
||||
let userJwt: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const api = await apiRequest.newContext();
|
||||
try {
|
||||
// Enable user registration
|
||||
await api.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { enable: true, enableMailVerify: false },
|
||||
});
|
||||
// Register user with hashed password (matching frontend behavior)
|
||||
await api.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
|
||||
});
|
||||
// Login to get JWT for localStorage injection
|
||||
const loginRes = await api.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
|
||||
});
|
||||
const body = await loginRes.json();
|
||||
userJwt = body.jwt;
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('register passkey, then login with passkey', async ({ page, context }) => {
|
||||
// Set up virtual authenticator via CDP
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send('WebAuthn.enable');
|
||||
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: 'ctap2',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// === Step 1: Login via localStorage injection ===
|
||||
// Inject JWT into localStorage to skip UI login flow.
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
// VueUse's useStorage with string default stores raw strings (no JSON)
|
||||
await page.evaluate((jwt) => {
|
||||
localStorage.setItem('userJwt', jwt);
|
||||
}, userJwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
// Wait for user settings to load (shows user email)
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// === Step 2: Click "User Settings" tab ===
|
||||
await page.getByText('User Settings').click();
|
||||
|
||||
// === Step 3: Create a passkey ===
|
||||
await page.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
// Fill passkey name in the modal
|
||||
const createModal = page.locator('.n-dialog');
|
||||
await expect(createModal).toBeVisible({ timeout: 5_000 });
|
||||
await createModal.getByRole('textbox').fill('E2E Test Passkey');
|
||||
|
||||
// Click the Create Passkey button inside the modal
|
||||
await createModal.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
// Wait for success — modal should close
|
||||
await expect(createModal).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// === Step 4: Verify passkey appears in the list ===
|
||||
await page.getByRole('button', { name: 'Show Passkey List' }).click();
|
||||
|
||||
const listModal = page.locator('.n-card-header:has-text("Show Passkey List")').locator('..');
|
||||
await expect(page.getByText('E2E Test Passkey')).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Close the list modal
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// === Step 5: Logout ===
|
||||
await page.getByRole('button', { name: 'Logout' }).click();
|
||||
const logoutModal = page.locator('.n-dialog');
|
||||
await expect(logoutModal).toBeVisible({ timeout: 5_000 });
|
||||
await logoutModal.getByRole('button', { name: 'Logout' }).click();
|
||||
|
||||
// Wait for logout to complete and navigate to user page
|
||||
await page.waitForTimeout(2000);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
// === Step 6: Login with passkey ===
|
||||
const passkeyBtn = page.getByRole('button', { name: 'Login with Passkey' });
|
||||
await expect(passkeyBtn).toBeVisible({ timeout: 10_000 });
|
||||
await passkeyBtn.click();
|
||||
|
||||
// Virtual authenticator handles the WebAuthn ceremony automatically
|
||||
// Wait for login to complete — user email should appear
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
||||
await cdp.detach();
|
||||
}
|
||||
});
|
||||
});
|
||||
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": "0.8.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -10,42 +10,50 @@
|
||||
"build:pages": "vite build -m pages --emptyOutDir",
|
||||
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
|
||||
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
|
||||
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
|
||||
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
|
||||
"preview": "vite preview",
|
||||
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
|
||||
"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": {
|
||||
"@simplewebauthn/browser": "^10.0.0",
|
||||
"@unhead/vue": "^1.11.11",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@unhead/vue": "^2.1.13",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.15.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.1.8",
|
||||
"naive-ui": "^2.40.1",
|
||||
"postal-mime": "^2.3.2",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.4",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.12",
|
||||
"vue": "^3.5.32",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^9.14.1",
|
||||
"vue-router": "^4.4.5"
|
||||
"vue-i18n": "^11.3.2",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.12.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"unplugin-auto-import": "^0.18.3",
|
||||
"unplugin-vue-components": "^0.27.4",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^0.19.8",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"workbox-window": "^7.3.0",
|
||||
"wrangler": "^3.84.1"
|
||||
}
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"jsdom": "^28.1.0",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"wrangler": "^4.81.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
6595
frontend/pnpm-lock.yaml
generated
6595
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useScript } from '@unhead/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from './store'
|
||||
import { useIsMobile } from './utils/composables'
|
||||
@@ -11,15 +12,26 @@ import { api } from './api'
|
||||
const {
|
||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||
} = useGlobalState()
|
||||
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
|
||||
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
|
||||
const { locale } = useI18n({});
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
|
||||
const isMobile = useIsMobile()
|
||||
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
|
||||
const showAd = computed(() => !isMobile.value && adClient && adSlot);
|
||||
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
|
||||
|
||||
// Load Google Ad script at top level (not inside onMounted)
|
||||
if (showAd.value) {
|
||||
useScript({
|
||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||
async: true,
|
||||
crossorigin: "anonymous",
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
try {
|
||||
await api.getUserSettings();
|
||||
} catch (error) {
|
||||
@@ -37,6 +49,13 @@ onMounted(async () => {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
// check if google ad is enabled
|
||||
if (showAd.value) {
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||
}
|
||||
|
||||
|
||||
// check if telegram is enabled
|
||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
||||
if (
|
||||
@@ -61,24 +80,36 @@ onMounted(async () => {
|
||||
<n-config-provider :locale="localeConfig" :theme="theme">
|
||||
<n-global-style />
|
||||
<n-spin description="loading..." :show="loading">
|
||||
<n-message-provider container-style="margin-top: 20px;">
|
||||
<n-grid x-gap="12" :cols="12">
|
||||
<n-gi v-if="showSideMargin" span="1"></n-gi>
|
||||
<n-gi :span="!showSideMargin ? 12 : 10">
|
||||
<div class="main">
|
||||
<n-space vertical>
|
||||
<n-layout style="min-height: 80vh;">
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
</n-layout>
|
||||
<Footer />
|
||||
</n-space>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi v-if="showSideMargin" span="1"></n-gi>
|
||||
</n-grid>
|
||||
<n-back-top />
|
||||
</n-message-provider>
|
||||
<n-notification-provider container-style="margin-top: 60px;">
|
||||
<n-message-provider container-style="margin-top: 20px;">
|
||||
<n-grid x-gap="12" :cols="gridMaxCols">
|
||||
<n-gi v-if="showSideMargin" span="1">
|
||||
<div class="side" v-if="showAd">
|
||||
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
|
||||
data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi :span="!showSideMargin ? gridMaxCols : (gridMaxCols - 2)">
|
||||
<div class="main">
|
||||
<n-space vertical>
|
||||
<n-layout style="min-height: 80vh;">
|
||||
<Header />
|
||||
<router-view></router-view>
|
||||
</n-layout>
|
||||
<Footer />
|
||||
</n-space>
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi v-if="showSideMargin" span="1">
|
||||
<div class="side" v-if="showAd">
|
||||
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
|
||||
data-ad-format="auto" data-full-width-responsive="true"></ins>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-back-top />
|
||||
</n-message-provider>
|
||||
</n-notification-provider>
|
||||
</n-spin>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useGlobalState } from '../store'
|
||||
import { h } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
import i18n from '../i18n'
|
||||
import { getFingerprint } from '../utils/fingerprint'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||
const {
|
||||
loading, auth, jwt, settings, openSettings,
|
||||
@@ -17,28 +21,31 @@ const instance = axios.create({
|
||||
const apiFetch = async (path, options = {}) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Get browser fingerprint for request tracking
|
||||
const fingerprint = await getFingerprint();
|
||||
|
||||
const response = await instance.request(path, {
|
||||
method: options.method || 'GET',
|
||||
data: options.body || null,
|
||||
headers: {
|
||||
'x-user-token': userJwt.value,
|
||||
'x-lang': i18n.global.locale.value,
|
||||
'x-user-token': options.userJwt || userJwt.value,
|
||||
'x-user-access-token': userSettings.value.access_token,
|
||||
'x-custom-auth': auth.value,
|
||||
'x-admin-auth': adminAuth.value,
|
||||
'x-fingerprint': fingerprint,
|
||||
'Authorization': `Bearer ${jwt.value}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
if (response.status === 401 && path.startsWith("/admin")) {
|
||||
showAdminAuth.value = true;
|
||||
throw new Error("Unauthorized, your admin password is wrong")
|
||||
}
|
||||
if (response.status === 401 && openSettings.value.auth) {
|
||||
if (response.status === 401 && openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
throw new Error("Unauthorized, you access password is wrong")
|
||||
}
|
||||
if (response.status >= 300) {
|
||||
throw new Error(`${response.status} ${response.data}` || "error");
|
||||
throw new Error(`[${response.status}]: ${response.data}` || "error");
|
||||
}
|
||||
const data = response.data;
|
||||
return data;
|
||||
@@ -52,7 +59,7 @@ const apiFetch = async (path, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getOpenSettings = async (message) => {
|
||||
const getOpenSettings = async (message, notification) => {
|
||||
try {
|
||||
const res = await api.fetch("/open_api/settings");
|
||||
const domainLabels = res["domainLabels"] || [];
|
||||
@@ -67,6 +74,7 @@ const getOpenSettings = async (message) => {
|
||||
maxAddressLen: res["maxAddressLen"] || 30,
|
||||
needAuth: res["needAuth"] || false,
|
||||
defaultDomains: res["defaultDomains"] || [],
|
||||
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
|
||||
domains: res["domains"].map((domain, index) => {
|
||||
return {
|
||||
label: domainLabels.length > index ? domainLabels[index] : domain,
|
||||
@@ -75,6 +83,8 @@ const getOpenSettings = async (message) => {
|
||||
}),
|
||||
adminContact: res["adminContact"] || "",
|
||||
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
|
||||
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
||||
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||
enableAutoReply: res["enableAutoReply"] || false,
|
||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||
@@ -82,16 +92,25 @@ const getOpenSettings = async (message) => {
|
||||
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||
enableWebhook: res["enableWebhook"] || false,
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||
statusUrl: res["statusUrl"] || "",
|
||||
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
||||
});
|
||||
if (openSettings.value.needAuth) {
|
||||
showAuth.value = true;
|
||||
}
|
||||
if (openSettings.value.announcement && openSettings.value.announcement != announcement.value) {
|
||||
if (openSettings.value.announcement
|
||||
&& !openSettings.value.fetched
|
||||
&& (openSettings.value.announcement != announcement.value
|
||||
|| openSettings.value.alwaysShowAnnouncement)
|
||||
) {
|
||||
announcement.value = openSettings.value.announcement;
|
||||
message.info(announcement.value, {
|
||||
showIcon: false,
|
||||
duration: 0,
|
||||
closable: true
|
||||
notification.info({
|
||||
content: () => {
|
||||
return h("div", {
|
||||
innerHTML: announcement.value
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -134,6 +153,19 @@ const getUserSettings = async (message) => {
|
||||
if (!userJwt.value) return;
|
||||
const res = await api.fetch("/user_api/settings")
|
||||
Object.assign(userSettings.value, res)
|
||||
// auto refresh user jwt
|
||||
if (userSettings.value.new_user_token) {
|
||||
try {
|
||||
await api.fetch("/user_api/settings", {
|
||||
userJwt: userSettings.value.new_user_token,
|
||||
})
|
||||
userJwt.value = userSettings.value.new_user_token;
|
||||
console.log("User JWT updated successfully");
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to update user JWT", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message?.error(error.message || "error");
|
||||
} finally {
|
||||
|
||||
260
frontend/src/components/AddressSelect.vue
Normal file
260
frontend/src/components/AddressSelect.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import useClipboard from 'vue-clipboard3'
|
||||
import { Copy } from '@vicons/fa'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
showCopy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'small',
|
||||
},
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const { toClipboard } = useClipboard()
|
||||
|
||||
const {
|
||||
jwt, settings, userJwt, isTelegram, openSettings, telegramApp
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
userAddresses: 'User Addresses',
|
||||
localAddresses: 'Local Addresses',
|
||||
address: 'Address',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
},
|
||||
zh: {
|
||||
userAddresses: '用户地址',
|
||||
localAddresses: '本地地址',
|
||||
address: '地址',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addressOptions = ref([])
|
||||
const addressValue = ref(null)
|
||||
const addressLoading = ref(false)
|
||||
const localAddressCache = useLocalStorage("LocalAddressCache", [])
|
||||
const optionValueMap = new Map()
|
||||
|
||||
const formatAddressLabel = (address) => {
|
||||
if (!address) return address;
|
||||
const domain = address.split('@')[1]
|
||||
const domainLabel = openSettings.value.domains.find(
|
||||
d => d.value === domain
|
||||
)?.label;
|
||||
if (!domainLabel) return address;
|
||||
return address.replace('@' + domain, `@${domainLabel}`);
|
||||
}
|
||||
|
||||
const parseJwtAddress = (curJwt) => {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
atob(curJwt.split(".")[1]
|
||||
.replace(/-/g, "+").replace(/_/g, "/")
|
||||
)
|
||||
)
|
||||
);
|
||||
return payload.address;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const getOptionValue = (key, scope, payload, address) => {
|
||||
if (optionValueMap.has(key)) {
|
||||
const cached = optionValueMap.get(key)
|
||||
cached.scope = scope
|
||||
cached.payload = payload
|
||||
cached.address = address
|
||||
return cached
|
||||
}
|
||||
const value = { key, scope, payload, address }
|
||||
optionValueMap.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
||||
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
const children = localAddressCache.value
|
||||
.map((curJwt) => {
|
||||
const address = parseJwtAddress(curJwt);
|
||||
if (!address) return null;
|
||||
if (excludeAddresses.has(address)) return null;
|
||||
const label = formatAddressLabel(address);
|
||||
const key = `local:${curJwt}`;
|
||||
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
|
||||
if (settings.value.address && address === settings.value.address) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
return option;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return children;
|
||||
}
|
||||
|
||||
const buildUserOptions = async () => {
|
||||
const children = [];
|
||||
try {
|
||||
const { results } = await api.fetch(`/user_api/bind_address`);
|
||||
for (const row of results || []) {
|
||||
const address = row.address || row.name;
|
||||
if (!address) continue;
|
||||
const label = formatAddressLabel(address);
|
||||
const key = `user:${row.id}`;
|
||||
const option = { label, value: getOptionValue(key, 'user', String(row.id), address), address };
|
||||
if (settings.value.address && address === settings.value.address) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
children.push(option);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
const buildTelegramOptions = async () => {
|
||||
const children = [];
|
||||
try {
|
||||
const data = await api.fetch(`/telegram/get_bind_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
initData: telegramApp.value.initData
|
||||
})
|
||||
});
|
||||
for (const row of data || []) {
|
||||
if (!row?.address || !row?.jwt) continue;
|
||||
const label = formatAddressLabel(row.address);
|
||||
const key = `tg:${row.jwt}`;
|
||||
const option = { label, value: getOptionValue(key, 'tg', row.jwt, row.address), address: row.address };
|
||||
if (settings.value.address && row.address === settings.value.address) {
|
||||
addressValue.value = option.value;
|
||||
}
|
||||
children.push(option);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
const refreshAddressOptions = async () => {
|
||||
addressLoading.value = true;
|
||||
addressValue.value = null;
|
||||
try {
|
||||
if (isTelegram.value) {
|
||||
const telegramChildren = await buildTelegramOptions();
|
||||
addressOptions.value = telegramChildren;
|
||||
return;
|
||||
}
|
||||
const groups = [];
|
||||
if (userJwt.value) {
|
||||
const userChildren = await buildUserOptions();
|
||||
if (userChildren.length > 0) {
|
||||
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
|
||||
}
|
||||
const userAddressSet = new Set(userChildren.map((item) => item.address));
|
||||
const localChildren = buildLocalOptions(userAddressSet);
|
||||
if (localChildren.length > 0) {
|
||||
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
|
||||
}
|
||||
} else {
|
||||
const localChildren = buildLocalOptions();
|
||||
if (localChildren.length > 0) {
|
||||
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
|
||||
}
|
||||
}
|
||||
addressOptions.value = groups;
|
||||
} finally {
|
||||
addressLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onAddressChange = async (value) => {
|
||||
if (!value) return;
|
||||
if (value.scope === 'local' || value.scope === 'tg') {
|
||||
jwt.value = value.payload;
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
if (value.scope === 'user') {
|
||||
try {
|
||||
const res = await api.fetch(`/user_api/bind_address_jwt/${value.payload}`);
|
||||
if (!res?.jwt) {
|
||||
message.error("jwt not found");
|
||||
return;
|
||||
}
|
||||
jwt.value = res.jwt;
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await toClipboard(settings.value.address)
|
||||
message.success(t('copied'));
|
||||
} catch (e) {
|
||||
message.error(e.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
|
||||
watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
||||
await refreshAddressOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex class="address-row" align="center" justify="center" :wrap="true">
|
||||
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
|
||||
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
|
||||
class="address-select" />
|
||||
<slot name="actions" />
|
||||
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
|
||||
<n-icon :component="Copy" /> {{ t('copy') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.address-row {
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.address-select {
|
||||
min-width: 220px;
|
||||
max-width: 420px;
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.address-copy {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
176
frontend/src/components/AiExtractInfo.vue
Normal file
176
frontend/src/components/AiExtractInfo.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ContentCopyOutlined, LinkRound, CodeRound } from '@vicons/material';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const message = useMessage();
|
||||
const { isDark } = useGlobalState();
|
||||
|
||||
// Dark mode: use Gmail's softer blue (#A8C7FA) for better readability
|
||||
const alertThemeOverrides = computed(() => {
|
||||
if (isDark.value) {
|
||||
return {
|
||||
colorSuccess: 'rgba(168, 199, 250, 0.15)',
|
||||
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
|
||||
iconColorSuccess: '#A8C7FA',
|
||||
titleTextColorSuccess: '#A8C7FA',
|
||||
}
|
||||
}
|
||||
return {}
|
||||
});
|
||||
|
||||
const tagThemeOverrides = computed(() => {
|
||||
if (isDark.value) {
|
||||
return {
|
||||
colorSuccess: 'rgba(168, 199, 250, 0.15)',
|
||||
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
|
||||
textColorSuccess: '#A8C7FA',
|
||||
}
|
||||
}
|
||||
return {}
|
||||
});
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
authCode: 'Verification Code',
|
||||
authLink: 'Authentication Link',
|
||||
serviceLink: 'Service Link',
|
||||
subscriptionLink: 'Subscription Link',
|
||||
otherLink: 'Other Link',
|
||||
copySuccess: 'Copied successfully',
|
||||
copyFailed: 'Copy failed',
|
||||
open: 'Open',
|
||||
},
|
||||
zh: {
|
||||
authCode: '验证码',
|
||||
authLink: '认证链接',
|
||||
serviceLink: '服务链接',
|
||||
subscriptionLink: '订阅链接',
|
||||
otherLink: '其他链接',
|
||||
copySuccess: '复制成功',
|
||||
copyFailed: '复制失败',
|
||||
open: '打开',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
metadata: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const aiExtract = computed(() => {
|
||||
if (!props.metadata) return null;
|
||||
try {
|
||||
const data = JSON.parse(props.metadata);
|
||||
return data.ai_extract || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
if (!aiExtract.value) return '';
|
||||
const typeMap = {
|
||||
auth_code: t('authCode'),
|
||||
auth_link: t('authLink'),
|
||||
service_link: t('serviceLink'),
|
||||
subscription_link: t('subscriptionLink'),
|
||||
other_link: t('otherLink'),
|
||||
};
|
||||
return typeMap[aiExtract.value.type] || '';
|
||||
});
|
||||
|
||||
const typeIcon = computed(() => {
|
||||
if (!aiExtract.value) return null;
|
||||
const iconMap = {
|
||||
auth_code: CodeRound,
|
||||
auth_link: LinkRound,
|
||||
service_link: LinkRound,
|
||||
subscription_link: LinkRound,
|
||||
other_link: LinkRound,
|
||||
};
|
||||
return iconMap[aiExtract.value.type] || null;
|
||||
});
|
||||
|
||||
const isLink = computed(() => {
|
||||
return aiExtract.value && aiExtract.value.type !== 'auth_code';
|
||||
});
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!aiExtract.value) return '';
|
||||
// For auth_code, always show the raw result (verification code)
|
||||
if (aiExtract.value.type === 'auth_code') {
|
||||
return aiExtract.value.result;
|
||||
}
|
||||
// For links, prefer result_text as display label
|
||||
return aiExtract.value.result_text || aiExtract.value.result;
|
||||
});
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(aiExtract.value.result);
|
||||
message.success(t('copySuccess'));
|
||||
} catch (e) {
|
||||
message.error(t('copyFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const openLink = () => {
|
||||
if (isLink.value && aiExtract.value.result) {
|
||||
window.open(aiExtract.value.result, '_blank');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="aiExtract && aiExtract.result" class="ai-extract-info">
|
||||
<n-alert v-if="!compact" type="success" closable :theme-overrides="alertThemeOverrides">
|
||||
<template #icon>
|
||||
<n-icon :component="typeIcon" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{ typeLabel }}
|
||||
</template>
|
||||
<n-space align="center">
|
||||
<n-text v-if="aiExtract.type === 'auth_code'" strong style="font-size: 18px; font-family: monospace;">
|
||||
{{ aiExtract.result }}
|
||||
</n-text>
|
||||
<n-ellipsis v-else style="max-width: 400px;">
|
||||
{{ displayText }}
|
||||
</n-ellipsis>
|
||||
<n-button size="small" @click="copyToClipboard" tertiary>
|
||||
<template #icon>
|
||||
<n-icon :component="ContentCopyOutlined" />
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button v-if="isLink" size="small" @click="openLink" tertiary type="primary">
|
||||
{{ t('open') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-alert>
|
||||
<n-tag v-else type="success" @click="copyToClipboard" style="cursor: pointer;" size="small" :theme-overrides="tagThemeOverrides">
|
||||
<template #icon>
|
||||
<n-icon :component="typeIcon" />
|
||||
</template>
|
||||
<n-ellipsis style="max-width: 150px;">
|
||||
{{ typeLabel }}: {{ displayText }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-extract-info {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
|
||||
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
|
||||
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";
|
||||
|
||||
const message = useMessage()
|
||||
const isMobile = useIsMobile()
|
||||
@@ -47,25 +50,87 @@ const props = defineProps({
|
||||
default: (mail_id, filename, blob) => { },
|
||||
required: false
|
||||
},
|
||||
showFilterInput: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const localFilterKeyword = ref('')
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
|
||||
useIframeShowMail, sendMailModel, preferShowTextMail
|
||||
autoRefresh, configAutoRefreshInterval, sendMailModel
|
||||
} = useGlobalState()
|
||||
const autoRefresh = ref(false)
|
||||
const autoRefreshInterval = ref(30)
|
||||
const data = ref([])
|
||||
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
|
||||
const rawData = ref([])
|
||||
const timer = ref(null)
|
||||
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const showAttachments = ref(false)
|
||||
const curAttachments = ref([])
|
||||
// Computed property for filtered data (only filter current page)
|
||||
const data = computed(() => {
|
||||
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
|
||||
return rawData.value;
|
||||
}
|
||||
const keyword = localFilterKeyword.value.toLowerCase();
|
||||
return rawData.value.filter(mail => {
|
||||
// Search in subject, text, message fields
|
||||
const searchFields = [
|
||||
mail.subject || '',
|
||||
mail.text || '',
|
||||
mail.message || ''
|
||||
].map(field => field.toLowerCase());
|
||||
return searchFields.some(field => field.includes(keyword));
|
||||
});
|
||||
})
|
||||
|
||||
const canGoPrevMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
return currentIndex > 0 || page.value > 1
|
||||
})
|
||||
|
||||
const canGoNextMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
return currentIndex < data.value.length - 1 || count.value > page.value * pageSize.value
|
||||
})
|
||||
|
||||
const prevMail = async () => {
|
||||
if (!canGoPrevMail.value) return
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
curMail.value = data.value[currentIndex - 1]
|
||||
} else if (page.value > 1) {
|
||||
page.value--
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[data.value.length - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextMail = async () => {
|
||||
if (!canGoNextMail.value) return
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex < data.value.length - 1) {
|
||||
curMail.value = data.value[currentIndex + 1]
|
||||
} else if (count.value > page.value * pageSize.value) {
|
||||
page.value++
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const curMail = ref(null);
|
||||
const showTextMail = ref(preferShowTextMail.value)
|
||||
|
||||
const multiActionMode = ref(false)
|
||||
const showMultiActionDownload = ref(false)
|
||||
@@ -83,9 +148,11 @@ 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',
|
||||
forwardMail: 'Forward',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show Html Mail',
|
||||
saveToS3: 'Save to S3',
|
||||
@@ -93,6 +160,10 @@ const { t } = useI18n({
|
||||
cancelMultiAction: 'Cancel Multi Action',
|
||||
selectAll: 'Select All of This Page',
|
||||
unselectAll: 'Unselect All',
|
||||
prevMail: 'Previous',
|
||||
nextMail: 'Next',
|
||||
keywordQueryTip: 'Filter current page',
|
||||
query: 'Query',
|
||||
},
|
||||
zh: {
|
||||
success: '成功',
|
||||
@@ -102,9 +173,11 @@ const { t } = useI18n({
|
||||
downloadMail: '下载邮件',
|
||||
attachments: '查看附件',
|
||||
pleaseSelectMail: "请选择邮件",
|
||||
emptyInbox: "收件箱为空",
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
reply: '回复',
|
||||
forwardMail: '转发',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件',
|
||||
saveToS3: '保存到S3',
|
||||
@@ -112,19 +185,25 @@ const { t } = useI18n({
|
||||
cancelMultiAction: '取消多选',
|
||||
selectAll: '全选本页',
|
||||
unselectAll: '取消全选',
|
||||
prevMail: '上一封',
|
||||
nextMail: '下一封',
|
||||
keywordQueryTip: '过滤当前页',
|
||||
query: '查询',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const setupAutoRefresh = async (autoRefresh) => {
|
||||
// auto refresh every 30 seconds
|
||||
autoRefreshInterval.value = 30;
|
||||
// auto refresh every configAutoRefreshInterval seconds
|
||||
autoRefreshInterval.value = configAutoRefreshInterval.value;
|
||||
if (autoRefresh) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = setInterval(async () => {
|
||||
if (loading.value) return;
|
||||
autoRefreshInterval.value--;
|
||||
if (autoRefreshInterval.value <= 0) {
|
||||
autoRefreshInterval.value = 30;
|
||||
await refresh();
|
||||
autoRefreshInterval.value = configAutoRefreshInterval.value;
|
||||
await backFirstPageAndRefresh();
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
@@ -135,7 +214,7 @@ const setupAutoRefresh = async (autoRefresh) => {
|
||||
|
||||
watch(autoRefresh, async (autoRefresh, old) => {
|
||||
setupAutoRefresh(autoRefresh)
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
if (page !== oldPage || pageSize !== oldPageSize) {
|
||||
@@ -149,7 +228,7 @@ const refresh = async () => {
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
);
|
||||
loading.value = true;
|
||||
data.value = await Promise.all(results.map(async (item) => {
|
||||
rawData.value = await Promise.all(results.map(async (item) => {
|
||||
item.checked = false;
|
||||
return await processItem(item);
|
||||
}));
|
||||
@@ -168,6 +247,11 @@ const refresh = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const backFirstPageAndRefresh = async () => {
|
||||
page.value = 1;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
const clickRow = async (row) => {
|
||||
if (multiActionMode.value) {
|
||||
row.checked = !row.checked;
|
||||
@@ -176,10 +260,6 @@ const clickRow = async (row) => {
|
||||
curMail.value = row;
|
||||
};
|
||||
|
||||
const getAttachments = (attachments) => {
|
||||
curAttachments.value = attachments;
|
||||
showAttachments.value = true;
|
||||
};
|
||||
|
||||
const mailItemClass = (row) => {
|
||||
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
|
||||
@@ -197,21 +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, buildForwardModel(curMail.value, t('forwardMail')));
|
||||
indexTab.value = 'sendmail';
|
||||
};
|
||||
|
||||
@@ -219,14 +290,8 @@ const onSpiltSizeChange = (size) => {
|
||||
mailboxSplitSize.value = size;
|
||||
}
|
||||
|
||||
const attachmentLoding = ref(false)
|
||||
const saveToS3Proxy = async (filename, blob) => {
|
||||
attachmentLoding.value = true
|
||||
try {
|
||||
await props.saveToS3(curMail.value.id, filename, blob);
|
||||
} finally {
|
||||
attachmentLoding.value = false
|
||||
}
|
||||
await props.saveToS3(curMail.value.id, filename, blob);
|
||||
}
|
||||
|
||||
const multiActionModeClick = (enableMulti) => {
|
||||
@@ -318,7 +383,7 @@ onBeforeUnmount(() => {
|
||||
<div>
|
||||
<div v-if="!isMobile" class="left">
|
||||
<div style="margin-bottom: 10px;">
|
||||
<n-space v-if="multiActionMode">
|
||||
<n-space v-if="multiActionMode" align="center">
|
||||
<n-button @click="multiActionModeClick(false)" tertiary>
|
||||
{{ t('cancelMultiAction') }}
|
||||
</n-button>
|
||||
@@ -341,7 +406,7 @@ onBeforeUnmount(() => {
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-space v-else>
|
||||
<n-space v-else align="center">
|
||||
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
|
||||
{{ t('multiAction') }}
|
||||
</n-button>
|
||||
@@ -355,15 +420,18 @@ onBeforeUnmount(() => {
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="refresh" type="primary" tertiary>
|
||||
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
|
||||
clearable />
|
||||
</n-space>
|
||||
</div>
|
||||
<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; 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)">
|
||||
@@ -379,11 +447,16 @@ onBeforeUnmount(() => {
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
{{ showEMailTo ? "FROM: " + row.source : row.source }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
TO: {{ row.address }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<AiExtractInfo :metadata="row.metadata" compact />
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
@@ -391,66 +464,45 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template #2>
|
||||
<div v-if="curMail" style="margin: 8px;">
|
||||
<n-flex justify="space-between">
|
||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevMail') }}
|
||||
</n-button>
|
||||
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowForwardIosFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('nextMail') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
|
||||
style="overflow: auto; max-height: 100vh;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail.raw)">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
|
||||
style="margin-top: 10px;width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
: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>
|
||||
</n-split>
|
||||
</div>
|
||||
<div class="left" v-else>
|
||||
<n-space justify="center">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
</div>
|
||||
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
|
||||
<n-switch v-model:value="autoRefresh" size="small" :round="false">
|
||||
<template #checked>
|
||||
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
|
||||
@@ -459,11 +511,15 @@ onBeforeUnmount(() => {
|
||||
{{ t('autoRefresh') }}
|
||||
</template>
|
||||
</n-switch>
|
||||
<n-button @click="refresh" tertiary size="small" type="primary">
|
||||
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div style="overflow: auto; height: 80vh;">
|
||||
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
|
||||
<n-input v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
||||
</div>
|
||||
<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">
|
||||
@@ -475,11 +531,16 @@ onBeforeUnmount(() => {
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ row.source }}
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
{{ showEMailTo ? "FROM: " + row.source : row.source }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ row.address }}
|
||||
<n-ellipsis style="max-width: 240px;">
|
||||
TO: {{ row.address }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<AiExtractInfo :metadata="row.metadata" compact />
|
||||
</template>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
@@ -489,83 +550,14 @@ onBeforeUnmount(() => {
|
||||
style="height: 80vh;">
|
||||
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
|
||||
<n-card :bordered="false" embedded style="overflow: auto;">
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ curMail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ curMail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ curMail.address }}
|
||||
</n-tag>
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="getAttachments(curMail.attachments)">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(curMail)">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<pre v-if="showTextMail" style="margin-top: 10px;">{{ curMail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="curMail.message"
|
||||
style="margin-top: 10px;width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||
:onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("attachments") }}</div>
|
||||
</template>
|
||||
<n-spin v-model:show="attachmentLoding">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
<n-button v-if="showSaveS3" @click="saveToS3Proxy(row.filename, row.blob)" ghost type="info"
|
||||
size="small">
|
||||
{{ t('saveToS3') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
|
||||
<n-tag type="info">
|
||||
{{ multiActionDownloadZip.filename }}
|
||||
|
||||
294
frontend/src/components/MailContentRenderer.vue
Normal file
294
frontend/src/components/MailContentRenderer.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
|
||||
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
delete: 'Delete',
|
||||
deleteMailTip: 'Are you sure you want to delete mail?',
|
||||
attachments: 'View Attachments',
|
||||
downloadMail: 'Download Mail',
|
||||
reply: 'Reply',
|
||||
forward: 'Forward',
|
||||
showTextMail: 'Show Text Mail',
|
||||
showHtmlMail: 'Show HTML Mail',
|
||||
saveToS3: 'Save to S3',
|
||||
size: 'Size',
|
||||
fullscreen: 'Fullscreen',
|
||||
},
|
||||
zh: {
|
||||
delete: '删除',
|
||||
deleteMailTip: '确定要删除邮件吗?',
|
||||
attachments: '查看附件',
|
||||
downloadMail: '下载邮件',
|
||||
reply: '回复',
|
||||
forward: '转发',
|
||||
showTextMail: '显示纯文本邮件',
|
||||
showHtmlMail: '显示HTML邮件',
|
||||
saveToS3: '保存到S3',
|
||||
size: '大小',
|
||||
fullscreen: '全屏',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
mail: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showEMailTo: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
enableUserDeleteEmail: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showReply: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showSaveS3: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 回调函数 props
|
||||
onDelete: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onReply: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onForward: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onSaveToS3: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
}
|
||||
});
|
||||
|
||||
const showTextMail = ref(preferShowTextMail.value);
|
||||
const showAttachments = ref(false);
|
||||
const curAttachments = ref([]);
|
||||
const attachmentLoding = ref(false);
|
||||
const showFullscreen = ref(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
props.onDelete();
|
||||
};
|
||||
|
||||
const handleViewAttachments = () => {
|
||||
curAttachments.value = props.mail.attachments;
|
||||
showAttachments.value = true;
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
props.onReply();
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
props.onForward();
|
||||
};
|
||||
|
||||
|
||||
const handleSaveToS3 = async (filename, blob) => {
|
||||
attachmentLoding.value = true;
|
||||
try {
|
||||
await props.onSaveToS3(filename, blob);
|
||||
} finally {
|
||||
attachmentLoding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mail-content-renderer">
|
||||
<!-- 邮件信息标签 -->
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
ID: {{ mail.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(mail.created_at, useUTCDate.value) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
FROM: {{ mail.source }}
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
TO: {{ mail.address }}
|
||||
</n-tag>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="handleDelete">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
|
||||
</template>
|
||||
{{ t('deleteMailTip') }}
|
||||
</n-popconfirm>
|
||||
|
||||
<n-button v-if="mail.attachments && mail.attachments.length > 0" size="small" tertiary type="info"
|
||||
@click="handleViewAttachments">
|
||||
{{ t('attachments') }}
|
||||
</n-button>
|
||||
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
|
||||
:href="getDownloadEmlUrl(mail.raw)">
|
||||
<template #icon>
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</template>
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
</template>
|
||||
{{ t('reply') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleForward">
|
||||
<template #icon>
|
||||
<n-icon :component="ForwardFilled" />
|
||||
</template>
|
||||
{{ t('forward') }}
|
||||
</n-button>
|
||||
|
||||
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
|
||||
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
|
||||
</n-button>
|
||||
|
||||
<n-button size="small" tertiary type="info" @click="showFullscreen = true">
|
||||
<template #icon>
|
||||
<n-icon :component="FullscreenRound" />
|
||||
</template>
|
||||
{{ t('fullscreen') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<!-- AI 提取信息 -->
|
||||
<AiExtractInfo :metadata="mail.metadata" />
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<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" :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" :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" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<!-- 附件模态框 -->
|
||||
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t('attachments') }}</div>
|
||||
</template>
|
||||
<n-spin v-model:show="attachmentLoding">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
|
||||
<n-thing class="center" :title="row.filename">
|
||||
<template #description>
|
||||
<n-space>
|
||||
<n-tag type="info">
|
||||
Size: {{ row.size }}
|
||||
</n-tag>
|
||||
<n-button v-if="showSaveS3" @click="handleSaveToS3(row.filename, row.blob)" ghost type="info"
|
||||
size="small">
|
||||
{{ t('saveToS3') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-thing>
|
||||
<template #suffix>
|
||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
|
||||
:href="row.url">
|
||||
<n-icon :component="CloudDownloadRound" />
|
||||
</n-button>
|
||||
</template>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-content-renderer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mail-content {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mail-text {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.dark-mode .mail-text {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.mail-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.dark-mode .mail-iframe {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.mail-html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fullscreen-mail-content {
|
||||
height: calc(100vh - 120px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fullscreen-mail-content .mail-iframe {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
</style>
|
||||
@@ -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">
|
||||
|
||||
85
frontend/src/components/ShadowHtmlComponent.vue
Normal file
85
frontend/src/components/ShadowHtmlComponent.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div v-if="useFallback" v-html="htmlContent"></div>
|
||||
<div v-else ref="shadowHost"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
htmlContent: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const shadowHost = ref(null);
|
||||
let shadowRoot = null;
|
||||
const useFallback = ref(false);
|
||||
|
||||
/**
|
||||
* Renders content into Shadow DOM with fallback to v-html
|
||||
*/
|
||||
const renderShadowDom = () => {
|
||||
if (!shadowHost.value && !useFallback.value) return;
|
||||
|
||||
try {
|
||||
// Don't attempt to use Shadow DOM if already in fallback mode
|
||||
if (useFallback.value) return;
|
||||
|
||||
// Initialize Shadow DOM if not already created
|
||||
if (!shadowRoot && shadowHost.value) {
|
||||
try {
|
||||
shadowRoot = shadowHost.value.attachShadow({ mode: 'open' });
|
||||
} catch (error) {
|
||||
console.warn('Shadow DOM not supported, falling back to v-html:', error);
|
||||
useFallback.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update content if Shadow DOM exists
|
||||
if (shadowRoot) {
|
||||
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);
|
||||
useFallback.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial render when component is mounted
|
||||
onMounted(() => {
|
||||
// Check if Shadow DOM is supported in this browser
|
||||
if (typeof Element.prototype.attachShadow !== 'function') {
|
||||
console.warn('Shadow DOM is not supported in this browser, using v-html fallback');
|
||||
useFallback.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
renderShadowDom();
|
||||
});
|
||||
|
||||
// Clean up resources when component is unmounted
|
||||
onBeforeUnmount(() => {
|
||||
if (shadowRoot) {
|
||||
shadowRoot.innerHTML = '';
|
||||
}
|
||||
shadowRoot = null;
|
||||
});
|
||||
|
||||
// Update Shadow DOM when htmlContent or dark mode changes
|
||||
watch(() => [props.htmlContent, props.isDark], () => {
|
||||
renderShadowDom();
|
||||
}, { flush: 'post' });
|
||||
</script>
|
||||
@@ -17,17 +17,21 @@ const { locale, t } = useI18n({
|
||||
}
|
||||
});
|
||||
|
||||
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
|
||||
const cfTurnstileId = ref("")
|
||||
const turnstileLoading = ref(false)
|
||||
|
||||
const refresh = () => checkCfTurnstile(true)
|
||||
defineExpose({ refresh })
|
||||
|
||||
const checkCfTurnstile = async (remove) => {
|
||||
if (!openSettings.value.cfTurnstileSiteKey) return;
|
||||
turnstileLoading.value = true;
|
||||
try {
|
||||
let container = document.getElementById("cf-turnstile");
|
||||
let container = document.getElementById(containerId);
|
||||
let count = 100;
|
||||
while (!container && count-- > 0) {
|
||||
container = document.getElementById("cf-turnstile");
|
||||
container = document.getElementById(containerId);
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
count = 100;
|
||||
@@ -38,7 +42,7 @@ const checkCfTurnstile = async (remove) => {
|
||||
window.turnstile.remove(cfTurnstileId.value);
|
||||
}
|
||||
cfTurnstileId.value = window.turnstile.render(
|
||||
"#cf-turnstile",
|
||||
`#${containerId}`,
|
||||
{
|
||||
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
|
||||
@@ -68,7 +72,7 @@ onMounted(() => {
|
||||
<n-spin description="loading..." :show="turnstileLoading">
|
||||
<n-form-item-row>
|
||||
<n-flex vertical>
|
||||
<div id="cf-turnstile"></div>
|
||||
<div :id="containerId"></div>
|
||||
<n-button text @click="checkCfTurnstile(true)">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
|
||||
@@ -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,131 @@ 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),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Telegram Bot',
|
||||
doc: 'https://core.telegram.org/bots/api#sendmessage',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"chat_id": "YOUR_CHAT_ID",
|
||||
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WeChat Work',
|
||||
doc: 'https://developer.work.weixin.qq.com/document/path/91770',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
doc: 'https://discord.com/developers/docs/resources/webhook',
|
||||
settings: {
|
||||
enabled: true,
|
||||
url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN',
|
||||
method: 'POST',
|
||||
headers: JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
}, null, 2),
|
||||
body: JSON.stringify({
|
||||
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
|
||||
}, null, 2),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const 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 +232,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>
|
||||
|
||||
15
frontend/src/i18n.ts
Normal file
15
frontend/src/i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // you must set `false`, to use Composition API
|
||||
locale: 'zh', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
'en': {
|
||||
messages: {}
|
||||
},
|
||||
'zh': {
|
||||
messages: {}
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n;
|
||||
@@ -1,29 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createHead } from '@unhead/vue/client'
|
||||
|
||||
import App from './App.vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import router from './router'
|
||||
import { createHead } from '@unhead/vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // you must set `false`, to use Composition API
|
||||
locale: 'zh', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
'en': {
|
||||
messages: {}
|
||||
},
|
||||
'zh': {
|
||||
messages: {}
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
|
||||
i18n.global.locale.value = to.params.lang
|
||||
} else {
|
||||
i18n.global.locale.value = 'zh'
|
||||
}
|
||||
});
|
||||
|
||||
import i18n from './i18n'
|
||||
|
||||
const head = createHead()
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,10 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Index from '../views/Index.vue'
|
||||
import User from '../views/User.vue'
|
||||
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
|
||||
import i18n from '../i18n'
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const { jwt } = useGlobalState()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -37,6 +41,20 @@ const router = createRouter({
|
||||
redirect: '/'
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
|
||||
i18n.global.locale.value = to.params.lang
|
||||
} else {
|
||||
i18n.global.locale.value = 'zh'
|
||||
}
|
||||
// check if query parameter has jwt, set it to store
|
||||
if (to.query.jwt) {
|
||||
jwt.value = to.query.jwt;
|
||||
}
|
||||
next()
|
||||
});
|
||||
|
||||
export default router
|
||||
|
||||
@@ -10,28 +10,38 @@ export const useGlobalState = createGlobalState(
|
||||
const toggleDark = useToggle(isDark)
|
||||
const loading = ref(false);
|
||||
const announcement = useLocalStorage('announcement', '');
|
||||
const useSimpleIndex = useLocalStorage('useSimpleIndex', false);
|
||||
const openSettings = ref({
|
||||
fetched: false,
|
||||
title: '',
|
||||
announcement: '',
|
||||
alwaysShowAnnouncement: false,
|
||||
prefix: '',
|
||||
addressRegex: '',
|
||||
needAuth: false,
|
||||
adminContact: '',
|
||||
enableUserCreateEmail: false,
|
||||
disableAnonymousUserCreateEmail: false,
|
||||
disableCustomAddressName: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableAutoReply: false,
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
defaultDomains: [],
|
||||
/** @type {string[]} */
|
||||
randomSubdomainDomains: [],
|
||||
/** @type {Array<{label: string, value: string}>} */
|
||||
domains: [],
|
||||
copyright: 'Dream Hunter',
|
||||
cfTurnstileSiteKey: '',
|
||||
enableWebhook: false,
|
||||
isS3Enabled: false,
|
||||
enableSendMail: false,
|
||||
showGithub: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
statusUrl: '',
|
||||
enableGlobalTurnstileCheck: false,
|
||||
})
|
||||
const settings = ref({
|
||||
fetched: false,
|
||||
@@ -59,6 +69,7 @@ export const useGlobalState = createGlobalState(
|
||||
const auth = useStorage('auth', '');
|
||||
const adminAuth = useStorage('adminAuth', '');
|
||||
const jwt = useStorage('jwt', '');
|
||||
const addressPassword = useSessionStorage('addressPassword', '');
|
||||
const adminTab = useSessionStorage('adminTab', "account");
|
||||
const adminMailTabAddress = ref("");
|
||||
const adminSendBoxTabAddress = ref("");
|
||||
@@ -66,16 +77,18 @@ export const useGlobalState = createGlobalState(
|
||||
const useIframeShowMail = useStorage('useIframeShowMail', false);
|
||||
const preferShowTextMail = useStorage('preferShowTextMail', false);
|
||||
const userJwt = useStorage('userJwt', '');
|
||||
const userTab = useSessionStorage('userTab', 'user_settings');
|
||||
const userTab = useSessionStorage('userTab', 'address_management');
|
||||
const indexTab = useSessionStorage('indexTab', 'mailbox');
|
||||
const globalTabplacement = useStorage('globalTabplacement', 'top');
|
||||
const useSideMargin = useStorage('useSideMargin', true);
|
||||
const useUTCDate = useStorage('useUTCDate', false);
|
||||
const autoRefresh = useStorage('autoRefresh', false);
|
||||
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
|
||||
const userOpenSettings = ref({
|
||||
fetched: false,
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
/** @type {{ clientID: string, name: string }[]} */
|
||||
/** @type {{ clientID: string, name: string, icon?: string }[]} */
|
||||
oauth2ClientIDs: [],
|
||||
});
|
||||
const userSettings = ref({
|
||||
@@ -89,6 +102,8 @@ export const useGlobalState = createGlobalState(
|
||||
is_admin: false,
|
||||
/** @type {string | null} */
|
||||
access_token: null,
|
||||
/** @type {string | null} */
|
||||
new_user_token: null,
|
||||
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
|
||||
user_role: null,
|
||||
});
|
||||
@@ -99,8 +114,19 @@ export const useGlobalState = createGlobalState(
|
||||
);
|
||||
const telegramApp = ref(window.Telegram?.WebApp || {});
|
||||
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
|
||||
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
|
||||
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
|
||||
const _oauth2StateSession = useSessionStorage('userOauth2SessionState', '');
|
||||
const _oauth2StateFallback = useStorage('userOauth2SessionState_fb', '');
|
||||
const userOauth2SessionState = computed({
|
||||
get: () => _oauth2StateSession.value || _oauth2StateFallback.value,
|
||||
set: (v) => { _oauth2StateSession.value = v; _oauth2StateFallback.value = v; }
|
||||
});
|
||||
const _oauth2ClientIDSession = useSessionStorage('userOauth2SessionClientID', '');
|
||||
const _oauth2ClientIDFallback = useStorage('userOauth2SessionClientID_fb', '');
|
||||
const userOauth2SessionClientID = computed({
|
||||
get: () => _oauth2ClientIDSession.value || _oauth2ClientIDFallback.value,
|
||||
set: (v) => { _oauth2ClientIDSession.value = v; _oauth2ClientIDFallback.value = v; }
|
||||
});
|
||||
const browserFingerprint = ref('');
|
||||
return {
|
||||
isDark,
|
||||
toggleDark,
|
||||
@@ -129,11 +155,16 @@ export const useGlobalState = createGlobalState(
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
useUTCDate,
|
||||
autoRefresh,
|
||||
configAutoRefreshInterval,
|
||||
telegramApp,
|
||||
isTelegram,
|
||||
showAdminPage,
|
||||
userOauth2SessionState,
|
||||
userOauth2SessionClientID,
|
||||
useSimpleIndex,
|
||||
addressPassword,
|
||||
browserFingerprint,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
30
frontend/src/utils/fingerprint.ts
Normal file
30
frontend/src/utils/fingerprint.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import FingerprintJS from '@fingerprintjs/fingerprintjs';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { browserFingerprint } = useGlobalState();
|
||||
|
||||
/**
|
||||
* Get browser fingerprint
|
||||
* Uses cached value from global state if available to avoid unnecessary computation
|
||||
* @returns Fingerprint visitor ID, or 'ERROR' if failed
|
||||
*/
|
||||
export const getFingerprint = async (): Promise<string> => {
|
||||
// Return cached fingerprint if available
|
||||
if (browserFingerprint.value) {
|
||||
return browserFingerprint.value;
|
||||
}
|
||||
|
||||
try {
|
||||
const fp = await FingerprintJS.load();
|
||||
const result = await fp.get();
|
||||
browserFingerprint.value = result.visitorId;
|
||||
return browserFingerprint.value;
|
||||
} catch (error) {
|
||||
console.error('Failed to get fingerprint:', error);
|
||||
// Return special error value to prevent blocking requests
|
||||
const errorValue = 'ERROR';
|
||||
browserFingerprint.value = errorValue;
|
||||
return errorValue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,6 +19,9 @@ export const utcToLocalDate = (utcDate: string, useUTCDate: boolean) => {
|
||||
}
|
||||
try {
|
||||
const date = new Date(utcDateString);
|
||||
// if invalid date string
|
||||
if (isNaN(date.getTime())) return utcDateString;
|
||||
|
||||
return date.toLocaleString();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang, hashPassword } from '../utils'
|
||||
import Turnstile from '../components/Turnstile.vue'
|
||||
|
||||
import SenderAccess from './admin/SenderAccess.vue'
|
||||
import Statistics from "./admin/Statistics.vue"
|
||||
@@ -14,37 +17,74 @@ import AccountSettings from './admin/AccountSettings.vue';
|
||||
import UserManagement from './admin/UserManagement.vue';
|
||||
import UserSettings from './admin/UserSettings.vue';
|
||||
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
|
||||
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
|
||||
import Mails from './admin/Mails.vue';
|
||||
import MailsUnknow from './admin/MailsUnknow.vue';
|
||||
import About from './common/About.vue';
|
||||
import Maintenance from './admin/Maintenance.vue';
|
||||
import DatabaseManager from './admin/DatabaseManager.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
import Telegram from './admin/Telegram.vue';
|
||||
import Webhook from './admin/Webhook.vue';
|
||||
import MailWebhook from './admin/MailWebhook.vue';
|
||||
import WorkerConfig from './admin/WorkerConfig.vue';
|
||||
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
|
||||
import AiExtractSettings from './admin/AiExtractSettings.vue';
|
||||
|
||||
const {
|
||||
adminAuth, showAdminAuth, adminTab, loading,
|
||||
globalTabplacement, showAdminPage, userSettings
|
||||
globalTabplacement, showAdminPage, userSettings,
|
||||
openSettings
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const SendMail = defineAsyncComponent(() => {
|
||||
loading.value = true;
|
||||
return import('./admin/SendMail.vue')
|
||||
.finally(() => loading.value = false);
|
||||
});
|
||||
|
||||
const cfToken = ref('')
|
||||
const turnstileRef = ref(null)
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
await api.fetch('/open_api/admin_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(tmpAdminAuth.value),
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
adminAuth.value = tmpAdminAuth.value;
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
turnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
const { t } = useI18n({
|
||||
const showLogoutModal = ref(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
// 清空管理员认证
|
||||
adminAuth.value = '';
|
||||
// 重置管理员相关状态
|
||||
showAdminAuth.value = false;
|
||||
adminTab.value = 'account';
|
||||
// 显示成功提示并跳转
|
||||
message.success(t('logoutSuccess'));
|
||||
await router.push(getRouterPathWithLang('/', locale.value));
|
||||
}
|
||||
|
||||
const { t, locale } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
accessHeader: 'Admin Password',
|
||||
accessTip: 'Please enter the admin password',
|
||||
mails: 'Emails',
|
||||
sendMail: 'Send Mail',
|
||||
qucickSetup: 'Quick Setup',
|
||||
account: 'Account',
|
||||
account_create: 'Create Account',
|
||||
@@ -53,6 +93,7 @@ const { t } = useI18n({
|
||||
user_management: 'User Management',
|
||||
user_settings: 'User Settings',
|
||||
userOauth2Settings: 'Oauth2 Settings',
|
||||
roleAddressConfig: 'Role Address Config',
|
||||
unknow: 'Mails with unknow receiver',
|
||||
senderAccess: 'Sender Access Control',
|
||||
sendBox: 'Send Box',
|
||||
@@ -60,16 +101,30 @@ const { t } = useI18n({
|
||||
webhookSettings: 'Webhook Settings',
|
||||
statistics: 'Statistics',
|
||||
maintenance: 'Maintenance',
|
||||
database: 'Database',
|
||||
workerconfig: 'Worker Config',
|
||||
ipBlacklistSettings: 'IP Blacklist',
|
||||
aiExtractSettings: 'AI Extract Settings',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
ok: 'OK',
|
||||
mailWebhook: 'Mail Webhook',
|
||||
adminAccount: 'Admin',
|
||||
loginMethod: 'Login Method',
|
||||
loginViaPassword: 'Admin Password Login',
|
||||
loginViaUserAdmin: 'User Admin Permission',
|
||||
loginViaDisabledCheck: 'Disabled Password Check',
|
||||
logout: 'Logout',
|
||||
logoutConfirmTitle: 'Confirm Logout',
|
||||
logoutConfirmContent: 'Are you sure you want to logout from admin panel?',
|
||||
confirm: 'Confirm',
|
||||
logoutSuccess: 'Logout successful',
|
||||
},
|
||||
zh: {
|
||||
accessHeader: 'Admin 密码',
|
||||
accessTip: '请输入 Admin 密码',
|
||||
mails: '邮件',
|
||||
sendMail: '发送邮件',
|
||||
qucickSetup: '快速设置',
|
||||
account: '账号',
|
||||
account_create: '创建账号',
|
||||
@@ -78,6 +133,7 @@ const { t } = useI18n({
|
||||
user_management: '用户管理',
|
||||
user_settings: '用户设置',
|
||||
userOauth2Settings: 'Oauth2 设置',
|
||||
roleAddressConfig: '角色地址配置',
|
||||
unknow: '无收件人邮件',
|
||||
senderAccess: '发件权限控制',
|
||||
sendBox: '发件箱',
|
||||
@@ -85,30 +141,60 @@ const { t } = useI18n({
|
||||
webhookSettings: 'Webhook 设置',
|
||||
statistics: '统计',
|
||||
maintenance: '维护',
|
||||
database: '数据库',
|
||||
workerconfig: 'Worker 配置',
|
||||
ipBlacklistSettings: 'IP 黑名单',
|
||||
aiExtractSettings: 'AI 提取设置',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
ok: '确定',
|
||||
mailWebhook: '邮件 Webhook',
|
||||
adminAccount: '管理员',
|
||||
loginMethod: '登录方式',
|
||||
loginViaPassword: 'Admin 密码登录',
|
||||
loginViaUserAdmin: '用户管理员权限',
|
||||
loginViaDisabledCheck: '已禁用密码检查',
|
||||
logout: '退出登录',
|
||||
logoutConfirmTitle: '确认退出',
|
||||
logoutConfirmContent: '确定要退出管理员面板吗?',
|
||||
confirm: '确认',
|
||||
logoutSuccess: '退出成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
|
||||
const tmpAdminAuth = ref('')
|
||||
// 判断是否通过 admin password 登录(而非用户管理员权限)
|
||||
const isAdminPasswordLogin = computed(() => !!adminAuth.value)
|
||||
|
||||
// 获取当前登录方式
|
||||
const currentLoginMethod = computed(() => {
|
||||
if (adminAuth.value) {
|
||||
return t('loginViaPassword');
|
||||
} else if (userSettings.value.is_admin) {
|
||||
return t('loginViaUserAdmin');
|
||||
} else if (openSettings.value.disableAdminPasswordCheck) {
|
||||
return t('loginViaDisabledCheck');
|
||||
}
|
||||
return '';
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure openSettings is fetched for turnstile check
|
||||
if (!openSettings.value.fetched) await api.getOpenSettings(message);
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="userSettings.fetched">
|
||||
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
|
||||
preset="dialog" :title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" type="primary" :loading="loading">
|
||||
{{ t('ok') }}
|
||||
@@ -118,6 +204,9 @@ onMounted(async () => {
|
||||
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
@@ -143,6 +232,12 @@ onMounted(async () => {
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
<SenderAccess />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
|
||||
<IpBlacklistSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="aiExtractSettings" :tab="t('aiExtractSettings')">
|
||||
<AiExtractSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
@@ -159,6 +254,9 @@ onMounted(async () => {
|
||||
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
|
||||
<UserOauth2Settings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
|
||||
<RoleAddressConfig />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
@@ -172,6 +270,9 @@ onMounted(async () => {
|
||||
<n-tab-pane name="sendBox" :tab="t('sendBox')">
|
||||
<SendBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendMail" :tab="t('sendMail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mailWebhook" :tab="t('mailWebhook')">
|
||||
<MailWebhook />
|
||||
</n-tab-pane>
|
||||
@@ -185,6 +286,9 @@ onMounted(async () => {
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
|
||||
<WorkerConfig />
|
||||
</n-tab-pane>
|
||||
@@ -196,10 +300,32 @@ onMounted(async () => {
|
||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||
<Appearance />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="adminAccount" :tab="t('adminAccount')">
|
||||
<div style="display: flex; justify-content: center; padding: 20px;">
|
||||
<n-card style="width: 600px;">
|
||||
<n-space vertical>
|
||||
<n-text strong>{{ t('loginMethod') }}</n-text>
|
||||
<n-text>{{ currentLoginMethod }}</n-text>
|
||||
<n-divider v-if="isAdminPasswordLogin" />
|
||||
<n-button v-if="isAdminPasswordLogin" type="warning" @click="showLogoutModal = true" block>
|
||||
{{ t('logout') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="about" :tab="t('about')">
|
||||
<About />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<n-modal v-model:show="showLogoutModal" preset="dialog" :title="t('logoutConfirmTitle')">
|
||||
<p>{{ t('logoutConfirmContent') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="handleLogout" size="small" tertiary type="warning">
|
||||
{{ t('confirm') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -21,9 +21,14 @@ const { t } = useI18n({
|
||||
<div>
|
||||
<n-divider class="footer-divider" />
|
||||
<div style="text-align: center; padding: 20px">
|
||||
<n-text depth="3">
|
||||
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }} {{ openSettings.copyright }}
|
||||
</n-text>
|
||||
<n-space justify="center">
|
||||
<n-text depth="3">
|
||||
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
<div v-html="openSettings.copyright"></div>
|
||||
</n-text>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,15 +6,17 @@ 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'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { getRouterPathWithLang } from '../utils'
|
||||
import { getRouterPathWithLang, hashPassword } from '../utils'
|
||||
import Turnstile from '../components/Turnstile.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const notification = useNotification()
|
||||
|
||||
const {
|
||||
toggleDark, isDark, isTelegram, showAdminPage,
|
||||
@@ -31,11 +33,22 @@ const menuValue = computed(() => {
|
||||
return "home";
|
||||
});
|
||||
|
||||
const cfToken = ref('')
|
||||
const turnstileRef = ref(null)
|
||||
|
||||
const authFunc = async () => {
|
||||
try {
|
||||
await api.fetch('/open_api/site_login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: await hashPassword(auth.value),
|
||||
cf_token: cfToken.value
|
||||
})
|
||||
});
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
turnstileRef.value?.refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +71,7 @@ const { locale, t } = useI18n({
|
||||
home: 'Home',
|
||||
menu: 'Menu',
|
||||
user: 'User',
|
||||
status: 'Status',
|
||||
ok: 'OK',
|
||||
},
|
||||
zh: {
|
||||
@@ -69,6 +83,7 @@ const { locale, t } = useI18n({
|
||||
home: '主页',
|
||||
menu: '菜单',
|
||||
user: '用户',
|
||||
status: '状态',
|
||||
ok: '确定',
|
||||
}
|
||||
}
|
||||
@@ -125,7 +140,9 @@ const menuOptions = computed(() => [
|
||||
type: menuValue.value == "admin" ? "primary" : "default",
|
||||
style: "width: 100%",
|
||||
onClick: async () => {
|
||||
loading.value = true;
|
||||
await router.push(getRouterPathWithLang('/admin', locale.value));
|
||||
loading.value = false;
|
||||
showMobileMenu.value = false;
|
||||
}
|
||||
},
|
||||
@@ -176,6 +193,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,
|
||||
@@ -213,7 +249,9 @@ const logoClick = async () => {
|
||||
if (logoClickCount.value >= 5) {
|
||||
logoClickCount.value = 0;
|
||||
message.info("Change to admin Page");
|
||||
loading.value = true;
|
||||
await router.push(getRouterPathWithLang('/admin', locale.value));
|
||||
loading.value = false;
|
||||
} else {
|
||||
logoClickCount.value++;
|
||||
}
|
||||
@@ -223,7 +261,7 @@ const logoClick = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getOpenSettings(message);
|
||||
await api.getOpenSettings(message, notification);
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
});
|
||||
@@ -261,6 +299,7 @@ onMounted(async () => {
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="auth" type="password" show-password-on="click" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
{{ t('ok') }}
|
||||
|
||||
@@ -5,20 +5,30 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
import { api } from '../api'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { FullscreenExitOutlined } from '@vicons/material'
|
||||
|
||||
import AddressBar from './index/AddressBar.vue';
|
||||
import MailBox from '../components/MailBox.vue';
|
||||
import SendBox from '../components/SendBox.vue';
|
||||
import AutoReply from './index/AutoReply.vue';
|
||||
import AccountSettings from './index/AccountSettings.vue';
|
||||
import Appearance from './common/Appearance.vue';
|
||||
import Webhook from './index/Webhook.vue';
|
||||
import Attachment from './index/Attachment.vue';
|
||||
import About from './common/About.vue';
|
||||
import SimpleIndex from './index/SimpleIndex.vue';
|
||||
|
||||
const SendMail = defineAsyncComponent(() => import('./index/SendMail.vue'));
|
||||
const { settings, openSettings, indexTab, globalTabplacement } = useGlobalState()
|
||||
const { loading, settings, openSettings, indexTab, globalTabplacement, useSimpleIndex } = useGlobalState()
|
||||
const message = useMessage()
|
||||
const route = useRoute()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const SendMail = defineAsyncComponent(() => {
|
||||
loading.value = true;
|
||||
return import('./index/SendMail.vue')
|
||||
.finally(() => loading.value = false);
|
||||
});
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
@@ -28,23 +38,27 @@ const { t } = useI18n({
|
||||
sendmail: 'Send Mail',
|
||||
auto_reply: 'Auto Reply',
|
||||
accountSettings: 'Account Settings',
|
||||
appearance: 'Appearance',
|
||||
about: 'About',
|
||||
s3Attachment: 'S3 Attachment',
|
||||
saveToS3Success: 'save to s3 success',
|
||||
webhookSettings: 'Webhook Settings',
|
||||
query: 'Query',
|
||||
enterSimpleMode: 'Simple Mode',
|
||||
},
|
||||
zh: {
|
||||
mailbox: '收件箱',
|
||||
sendbox: '发件箱',
|
||||
sendmail: '发送邮件',
|
||||
auto_reply: '自动回复',
|
||||
accountSettings: '账户设置',
|
||||
accountSettings: '账户',
|
||||
appearance: '外观',
|
||||
about: '关于',
|
||||
s3Attachment: 'S3附件',
|
||||
saveToS3Success: '保存到s3成功',
|
||||
webhookSettings: 'Webhook 设置',
|
||||
query: '查询',
|
||||
enterSimpleMode: '极简模式',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -117,43 +131,61 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AddressBar />
|
||||
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
||||
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailIdQuery" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="true" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:deleteMail="deleteSenboxMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
|
||||
<AutoReply />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
|
||||
<Attachment />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
|
||||
<About />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
<div v-if="useSimpleIndex">
|
||||
<SimpleIndex />
|
||||
</div>
|
||||
<div v-else>
|
||||
<AddressBar />
|
||||
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
|
||||
<template #prefix v-if="!isMobile">
|
||||
<n-button @click="useSimpleIndex = true" tertiary size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<FullscreenExitOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('enterSimpleMode') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
||||
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="mailIdQuery" />
|
||||
<n-button @click="queryMail" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
</div>
|
||||
<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 v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:deleteMail="deleteSenboxMail" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
|
||||
<SendMail />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||
<Appearance :showUseSimpleIndex="true" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
|
||||
<AutoReply />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
|
||||
<Webhook />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
|
||||
<Attachment />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
|
||||
<About />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import AddressMangement from './user/AddressManagement.vue';
|
||||
import UserSettingsPage from './user/UserSettings.vue';
|
||||
import UserBar from './user/UserBar.vue';
|
||||
import BindAddress from './user/BindAddress.vue';
|
||||
import UserMailBox from './user/UserMailBox.vue';
|
||||
|
||||
const {
|
||||
userTab, globalTabplacement, userSettings
|
||||
@@ -16,11 +17,13 @@ const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
address_management: 'Address Management',
|
||||
user_mail_box_tab: 'Mail Box',
|
||||
user_settings: 'User Settings',
|
||||
bind_address: 'Bind Mail Address',
|
||||
},
|
||||
zh: {
|
||||
address_management: '地址管理',
|
||||
user_mail_box_tab: '收件箱',
|
||||
user_settings: '用户设置',
|
||||
bind_address: '绑定邮箱地址',
|
||||
}
|
||||
@@ -36,6 +39,9 @@ const { t } = useI18n({
|
||||
<n-tab-pane name="address_management" :tab="t('address_management')">
|
||||
<AddressMangement />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||
<UserMailBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { NBadge } from 'naive-ui'
|
||||
import { ref, h, onMounted, watch, computed } from 'vue';
|
||||
import { NBadge, useMessage } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
@@ -9,7 +9,7 @@ import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
|
||||
const {
|
||||
loading, adminTab,
|
||||
loading, adminTab, openSettings,
|
||||
adminMailTabAddress, adminSendBoxTabAddress
|
||||
} = useGlobalState()
|
||||
const message = useMessage()
|
||||
@@ -22,18 +22,37 @@ const { t } = useI18n({
|
||||
updated_at: 'Update At',
|
||||
mail_count: 'Mail Count',
|
||||
send_count: 'Send Count',
|
||||
source_meta: 'Source',
|
||||
showCredential: 'Show Mail Address Credential',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
delete: 'Delete',
|
||||
deleteTip: 'Are you sure to delete this email?',
|
||||
delteAccount: 'Delete Account',
|
||||
deleteAccount: 'Delete Account',
|
||||
viewMails: 'View Mails',
|
||||
viewSendBox: 'View SendBox',
|
||||
itemCount: 'itemCount',
|
||||
query: 'Query',
|
||||
addressQueryTip: 'Leave blank to query all addresses',
|
||||
actions: 'Actions'
|
||||
clearInbox: 'Clear Inbox',
|
||||
clearSentItems: 'Clear Sent Items',
|
||||
clearInboxTip: 'Are you sure to clear inbox for this email?',
|
||||
clearSentItemsTip: 'Are you sure to clear sent items for this email?',
|
||||
actions: 'Actions',
|
||||
success: 'Success',
|
||||
resetPassword: 'Reset Password',
|
||||
newPassword: 'New Password',
|
||||
passwordResetSuccess: 'Password reset successfully',
|
||||
selectAll: 'Select All of This Page',
|
||||
unselectAll: 'Unselect All',
|
||||
pleaseSelectAddress: 'Please select address',
|
||||
selectedItems: 'Selected',
|
||||
multiDelete: 'Multi Delete',
|
||||
multiDeleteTip: 'Are you sure to delete selected addresses?',
|
||||
multiClearInbox: 'Multi Clear Inbox',
|
||||
multiClearInboxTip: 'Are you sure to clear inbox for selected addresses?',
|
||||
multiClearSentItems: 'Multi Clear Sent Items',
|
||||
multiClearSentItemsTip: 'Are you sure to clear sent items for selected addresses?',
|
||||
},
|
||||
zh: {
|
||||
name: '名称',
|
||||
@@ -41,18 +60,37 @@ const { t } = useI18n({
|
||||
updated_at: '更新时间',
|
||||
mail_count: '邮件数量',
|
||||
send_count: '发送数量',
|
||||
source_meta: '来源',
|
||||
showCredential: '查看邮箱地址凭证',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
delete: '删除',
|
||||
deleteTip: '确定要删除这个邮箱吗?',
|
||||
delteAccount: '删除邮箱',
|
||||
deleteAccount: '删除邮箱',
|
||||
viewMails: '查看邮件',
|
||||
viewSendBox: '查看发件箱',
|
||||
itemCount: '总数',
|
||||
query: '查询',
|
||||
addressQueryTip: '留空查询所有地址',
|
||||
clearInbox: '清空收件箱',
|
||||
clearSentItems: '清空发件箱',
|
||||
clearInboxTip: '确定要清空这个邮箱的收件箱吗?',
|
||||
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
|
||||
actions: '操作',
|
||||
success: '成功',
|
||||
resetPassword: '重置密码',
|
||||
newPassword: '新密码',
|
||||
passwordResetSuccess: '密码重置成功',
|
||||
selectAll: '全选本页',
|
||||
unselectAll: '取消全选',
|
||||
pleaseSelectAddress: '请选择地址',
|
||||
selectedItems: '已选择',
|
||||
multiDelete: '批量删除',
|
||||
multiDeleteTip: '确定要删除选中的邮箱吗?',
|
||||
multiClearInbox: '批量清空收件箱',
|
||||
multiClearInboxTip: '确定要清空选中邮箱的收件箱吗?',
|
||||
multiClearSentItems: '批量清空发件箱',
|
||||
multiClearSentItemsTip: '确定要清空选中邮箱的发件箱吗?',
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -60,14 +98,32 @@ const { t } = useI18n({
|
||||
const showEmailCredential = ref(false)
|
||||
const curEmailCredential = ref("")
|
||||
const curDeleteAddressId = ref(0);
|
||||
const curClearInboxAddressId = ref(0);
|
||||
const curClearSentItemsAddressId = ref(0);
|
||||
const showResetPassword = ref(false);
|
||||
const curResetPasswordAddressId = ref(0);
|
||||
const newPassword = ref('');
|
||||
|
||||
// Multi-action mode state
|
||||
const checkedRowKeys = ref([]);
|
||||
const showMultiActionModal = ref(false);
|
||||
const multiActionProgress = ref({ percentage: 0, tip: '0/0' });
|
||||
const multiActionTitle = ref('');
|
||||
|
||||
const selectedCount = computed(() => checkedRowKeys.value.length);
|
||||
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
|
||||
|
||||
const addressQuery = ref("")
|
||||
const sortBy = ref("")
|
||||
const sortOrder = ref("")
|
||||
|
||||
const data = ref([])
|
||||
const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const showDeleteAccount = ref(false)
|
||||
const showClearInbox = ref(false)
|
||||
const showClearSentItems = ref(false)
|
||||
|
||||
const showCredential = async (id) => {
|
||||
try {
|
||||
@@ -83,7 +139,7 @@ const showCredential = async (id) => {
|
||||
const deleteEmail = async () => {
|
||||
try {
|
||||
await api.adminDeleteAddress(curDeleteAddressId.value)
|
||||
message.success("success");
|
||||
message.success(t("success"));
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -92,6 +148,142 @@ const deleteEmail = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const clearInbox = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/clear_inbox/${curClearInboxAddressId.value}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
message.success(t("success"));
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
showClearInbox.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearSentItems = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/clear_sent_items/${curClearSentItemsAddressId.value}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
message.success(t("success"));
|
||||
await fetchData()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
showClearSentItems.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: newPassword.value
|
||||
})
|
||||
});
|
||||
message.success(t("passwordResetSuccess"));
|
||||
newPassword.value = '';
|
||||
showResetPassword.value = false;
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-action mode functions
|
||||
const multiActionSelectAll = () => {
|
||||
checkedRowKeys.value = data.value.map(item => item.id);
|
||||
}
|
||||
|
||||
const multiActionUnselectAll = () => {
|
||||
checkedRowKeys.value = [];
|
||||
}
|
||||
|
||||
// 通用批量操作函数
|
||||
const executeBatchOperation = async ({
|
||||
shouldSkip = () => false,
|
||||
apiCall,
|
||||
title,
|
||||
operationName = 'operation'
|
||||
}) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const selectedAddresses = data.value.filter((item) =>
|
||||
checkedRowKeys.value.includes(item.id)
|
||||
);
|
||||
|
||||
if (selectedAddresses.length === 0) {
|
||||
message.error(t('pleaseSelectAddress'));
|
||||
return;
|
||||
}
|
||||
|
||||
const failedIds = [];
|
||||
const totalCount = selectedAddresses.length;
|
||||
|
||||
multiActionProgress.value = {
|
||||
percentage: 0,
|
||||
tip: `0/${totalCount}`
|
||||
};
|
||||
multiActionTitle.value = title;
|
||||
showMultiActionModal.value = true;
|
||||
|
||||
for (const [index, address] of selectedAddresses.entries()) {
|
||||
try {
|
||||
if (!shouldSkip(address)) {
|
||||
await apiCall(address.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${operationName} failed for address ${address.id}:`, error);
|
||||
failedIds.push(address.id);
|
||||
}
|
||||
multiActionProgress.value = {
|
||||
percentage: Math.floor((index + 1) / totalCount * 100),
|
||||
tip: `${index + 1}/${totalCount}`
|
||||
};
|
||||
}
|
||||
|
||||
await fetchData();
|
||||
checkedRowKeys.value = failedIds;
|
||||
message.success(t("success"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const multiActionDeleteAccounts = async () => {
|
||||
await executeBatchOperation({
|
||||
apiCall: (id) => api.adminDeleteAddress(id),
|
||||
title: t('multiDelete') + ' ' + t('success'),
|
||||
operationName: 'Delete'
|
||||
});
|
||||
}
|
||||
|
||||
const multiActionClearInbox = async () => {
|
||||
await executeBatchOperation({
|
||||
shouldSkip: (address) => address.mail_count <= 0,
|
||||
apiCall: (id) => api.fetch(`/admin/clear_inbox/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
title: t('multiClearInbox') + ' ' + t('success'),
|
||||
operationName: 'ClearInbox'
|
||||
});
|
||||
}
|
||||
|
||||
const multiActionClearSentItems = async () => {
|
||||
await executeBatchOperation({
|
||||
shouldSkip: (address) => address.send_count <= 0,
|
||||
apiCall: (id) => api.fetch(`/admin/clear_sent_items/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
title: t('multiClearSentItems') + ' ' + t('success'),
|
||||
operationName: 'ClearSentItems'
|
||||
});
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
addressQuery.value = addressQuery.value.trim()
|
||||
@@ -100,37 +292,90 @@ const fetchData = async () => {
|
||||
+ `?limit=${pageSize.value}`
|
||||
+ `&offset=${(page.value - 1) * pageSize.value}`
|
||||
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
|
||||
+ (sortBy.value ? `&sort_by=${sortBy.value}` : "")
|
||||
+ (sortOrder.value ? `&sort_order=${sortOrder.value}` : "")
|
||||
);
|
||||
data.value = results;
|
||||
if (addressCount > 0) {
|
||||
count.value = addressCount;
|
||||
if (page.value === 1 || addressCount > 0) {
|
||||
count.value = addressCount ?? 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
console.error(error);
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const searchData = () => {
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSorterChange = (sorter) => {
|
||||
sortBy.value = sorter.columnKey || "";
|
||||
sortOrder.value = sorter.order || "";
|
||||
if (page.value === 1) {
|
||||
fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
type: 'selection'
|
||||
},
|
||||
{
|
||||
title: "ID",
|
||||
key: "id"
|
||||
key: "id",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('name'),
|
||||
key: "name"
|
||||
key: "name",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('created_at'),
|
||||
key: "created_at"
|
||||
key: "created_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('updated_at'),
|
||||
key: "updated_at"
|
||||
key: "updated_at",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
|
||||
},
|
||||
{
|
||||
title: t('source_meta'),
|
||||
key: "source_meta",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'source_meta' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
const val = row.source_meta;
|
||||
if (!val) return '';
|
||||
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'),
|
||||
key: "mail_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -157,6 +402,8 @@ const columns = [
|
||||
{
|
||||
title: t('send_count'),
|
||||
key: "send_count",
|
||||
sorter: true,
|
||||
sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false,
|
||||
render(row) {
|
||||
return h(NButton,
|
||||
{
|
||||
@@ -212,7 +459,8 @@ const columns = [
|
||||
}
|
||||
},
|
||||
{ default: () => t('viewMails') }
|
||||
)
|
||||
),
|
||||
show: row.mail_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
@@ -224,7 +472,47 @@ const columns = [
|
||||
}
|
||||
},
|
||||
{ default: () => t('viewSendBox') }
|
||||
)
|
||||
),
|
||||
show: row.send_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curClearInboxAddressId.value = row.id;
|
||||
showClearInbox.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('clearInbox') }
|
||||
),
|
||||
show: row.mail_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curClearSentItemsAddressId.value = row.id;
|
||||
showClearSentItems.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('clearSentItems') }
|
||||
),
|
||||
show: row.send_count > 0
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => {
|
||||
curResetPasswordAddressId.value = row.id;
|
||||
showResetPassword.value = true;
|
||||
}
|
||||
},
|
||||
{ default: () => t('resetPassword') }
|
||||
),
|
||||
show: openSettings.value?.enableAddressPassword
|
||||
},
|
||||
{
|
||||
label: () => h(NButton,
|
||||
@@ -245,7 +533,7 @@ const columns = [
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData()
|
||||
@@ -271,21 +559,78 @@ onMounted(async () => {
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('delteAccount')">
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
||||
<p>{{ t('deleteTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
|
||||
{{ t('delteAccount') }}
|
||||
{{ t('deleteAccount') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-input-group>
|
||||
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
|
||||
<p>{{ t('clearInboxTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="error">
|
||||
{{ t('clearInbox') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
|
||||
<p>{{ t('clearSentItemsTip') }}</p>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="error">
|
||||
{{ t('clearSentItems') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
|
||||
<n-form-item :label="t('newPassword')">
|
||||
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
|
||||
</n-form-item>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
|
||||
{{ t('resetPassword') }}
|
||||
</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
<n-input-group style="margin-bottom: 10px;">
|
||||
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
|
||||
@keydown.enter="fetchData" />
|
||||
<n-button @click="fetchData" type="primary" tertiary>
|
||||
@keydown.enter="searchData" />
|
||||
<n-button @click="searchData" type="primary" tertiary>
|
||||
{{ t('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
|
||||
<n-space v-if="showMultiActionBar" style="margin-bottom: 10px;">
|
||||
<n-button @click="multiActionSelectAll" tertiary>
|
||||
{{ t('selectAll') }}
|
||||
</n-button>
|
||||
<n-button @click="multiActionUnselectAll" tertiary>
|
||||
{{ t('unselectAll') }}
|
||||
</n-button>
|
||||
<n-popconfirm @positive-click="multiActionDeleteAccounts">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="error">{{ t('multiDelete') }}</n-button>
|
||||
</template>
|
||||
{{ t('multiDeleteTip') }}
|
||||
</n-popconfirm>
|
||||
<n-popconfirm @positive-click="multiActionClearInbox">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="warning">{{ t('multiClearInbox') }}</n-button>
|
||||
</template>
|
||||
{{ t('multiClearInboxTip') }}
|
||||
</n-popconfirm>
|
||||
<n-popconfirm @positive-click="multiActionClearSentItems">
|
||||
<template #trigger>
|
||||
<n-button tertiary type="warning">{{ t('multiClearSentItems') }}</n-button>
|
||||
</template>
|
||||
{{ t('multiClearSentItemsTip') }}
|
||||
</n-popconfirm>
|
||||
<n-tag type="info">
|
||||
{{ t('selectedItems') }}: {{ selectedCount }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<div style="overflow: auto;">
|
||||
<div style="display: inline-block;">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
@@ -295,8 +640,21 @@ onMounted(async () => {
|
||||
</template>
|
||||
</n-pagination>
|
||||
</div>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
|
||||
:row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded />
|
||||
</div>
|
||||
|
||||
<!-- Multi-action progress modal -->
|
||||
<n-modal v-model:show="showMultiActionModal" preset="dialog" :title="multiActionTitle" negative-text="OK">
|
||||
<n-space justify="center">
|
||||
<n-progress type="circle" status="info" :percentage="multiActionProgress.percentage">
|
||||
<span style="text-align: center">
|
||||
{{ multiActionProgress.tip }}
|
||||
</span>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,104 +1,501 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, h } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const { loading, openSettings } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
tip: 'You can manually input the following multiple select input',
|
||||
tip: 'You can manually input the following multiple select input and enter',
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
|
||||
address_block_list_placeholder: 'Please enter the keywords you want to block',
|
||||
send_address_block_list: 'Address Block Keywords for send email',
|
||||
noLimitSendAddressList: 'No Balance Limit Send Address List',
|
||||
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
|
||||
fromBlockList: 'Block Keywords for receive email',
|
||||
block_receive_unknow_address_email: 'Block receive unknow address email',
|
||||
email_forwarding_config: 'Email Forwarding Configuration',
|
||||
domain_list: 'Domain List (Optional)',
|
||||
forward_address: 'Forward Address',
|
||||
actions: 'Actions',
|
||||
select_domain: 'Select Domain',
|
||||
forward_placeholder: 'forward@example.com',
|
||||
delete_rule: 'Delete',
|
||||
delete_rule_confirm: 'Are you sure you want to delete this rule?',
|
||||
delete_success: 'Delete Success',
|
||||
forwarding_rule_warning: 'Each rule will run independently. Forward address needs to be a verified address.',
|
||||
add: 'Add',
|
||||
cancel: 'Cancel',
|
||||
config: 'Config',
|
||||
source_patterns: 'Source Address Regex (Optional)',
|
||||
source_patterns_placeholder: 'e.g. gmail.com',
|
||||
source_match_mode: 'Match Mode',
|
||||
match_any: 'Any',
|
||||
match_all: 'All',
|
||||
source_patterns_tip: 'Domain list filters by recipient address, source regex filters by sender address. Both conditions must match for forwarding (AND logic). Leave either empty to skip that filter.',
|
||||
regex_too_long: 'Regex pattern too long (max 200 characters)',
|
||||
regex_invalid: 'Invalid regex pattern',
|
||||
forward_address_required: 'Forward address is required',
|
||||
rule_index: 'Rule',
|
||||
create_address_subdomain_match: 'Allow Subdomain Suffix Match When Creating Address',
|
||||
create_address_subdomain_match_tip: 'Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.',
|
||||
create_address_subdomain_match_note: 'This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.',
|
||||
create_address_subdomain_match_follow_env: 'Follow Environment Variable',
|
||||
create_address_subdomain_match_force_enable: 'Force Enable',
|
||||
create_address_subdomain_match_force_disable: 'Force Disable',
|
||||
create_address_subdomain_match_follow_env_note: 'Choosing "Follow Environment Variable" clears the admin override and returns to the unset state. The effective result is still controlled by the Worker env and the precedence rules.',
|
||||
create_address_subdomain_match_env_locked: 'Worker env ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH is currently false. The saved admin switch can be modified, but it will not take effect until env is enabled or removed.',
|
||||
},
|
||||
zh: {
|
||||
tip: '您可以手动输入以下多选输入框',
|
||||
tip: '您可以手动输入以下多选输入框, 回车增加',
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
|
||||
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
|
||||
send_address_block_list: '发送邮件地址屏蔽关键词',
|
||||
noLimitSendAddressList: '无余额限制发送地址列表',
|
||||
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
|
||||
fromBlockList: '接收邮件地址屏蔽关键词',
|
||||
block_receive_unknow_address_email: '禁止接收未知地址邮件',
|
||||
email_forwarding_config: '邮件转发配置',
|
||||
domain_list: '域名列表(可选)',
|
||||
forward_address: '转发地址',
|
||||
actions: '操作',
|
||||
select_domain: '选择域名',
|
||||
forward_placeholder: 'forward@example.com',
|
||||
delete_rule: '删除',
|
||||
delete_rule_confirm: '确定要删除这条规则吗?',
|
||||
delete_success: '删除成功',
|
||||
forwarding_rule_warning: '每条规则独立运行,转发地址需要为已验证的地址。',
|
||||
add: '添加',
|
||||
cancel: '取消',
|
||||
config: '配置',
|
||||
source_patterns: '来源地址正则(可选)',
|
||||
source_patterns_placeholder: '例如: gmail.com',
|
||||
source_match_mode: '匹配模式',
|
||||
match_any: '任一',
|
||||
match_all: '全部',
|
||||
source_patterns_tip: '域名列表按收件地址过滤,来源正则按发件地址过滤,两者均为可选。同时配置时需同时满足(AND 逻辑),留空则跳过该条件。',
|
||||
regex_too_long: '正则表达式过长(最大200字符)',
|
||||
regex_invalid: '无效的正则表达式',
|
||||
forward_address_required: '转发地址不能为空',
|
||||
rule_index: '规则',
|
||||
create_address_subdomain_match: '创建邮箱时允许子域名后缀匹配',
|
||||
create_address_subdomain_match_tip: '仅影响 /api/new_address 和 /admin/new_address 的域名校验。例如开启后,foo.example.com 可以匹配已配置的基础域名 example.com。',
|
||||
create_address_subdomain_match_note: '这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名;随机子域名功能只是在创建时自动补一个随机子域名。',
|
||||
create_address_subdomain_match_follow_env: '跟随环境变量',
|
||||
create_address_subdomain_match_force_enable: '强制开启',
|
||||
create_address_subdomain_match_force_disable: '强制关闭',
|
||||
create_address_subdomain_match_follow_env_note: '选择“跟随环境变量”会清空后台覆盖,恢复为未设置状态;最终是否开启仍由 Worker env 和优先级规则决定。',
|
||||
create_address_subdomain_match_env_locked: '当前 Worker 环境变量 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH 为 false。后台开关仍可保存,但在 env 打开或移除前不会生效。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const addressBlockList = ref([])
|
||||
const sendAddressBlockList = ref([])
|
||||
const noLimitSendAddressList = ref([])
|
||||
const verifiedAddressList = ref([])
|
||||
const fromBlockList = ref([])
|
||||
const emailRuleSettings = ref({
|
||||
blockReceiveUnknowAddressEmail: false,
|
||||
emailForwardingList: []
|
||||
})
|
||||
const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
|
||||
FOLLOW_ENV: 'follow_env',
|
||||
FORCE_ENABLE: 'force_enable',
|
||||
FORCE_DISABLE: 'force_disable'
|
||||
}
|
||||
const addressCreationSubdomainMatchMode = ref(ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV)
|
||||
const addressCreationSubdomainMatchStatus = ref({
|
||||
envConfigured: false,
|
||||
envEnabled: false,
|
||||
storedEnabled: undefined,
|
||||
effectiveEnabled: false
|
||||
})
|
||||
const subdomainMatchEnvLocked = computed(() => {
|
||||
return addressCreationSubdomainMatchStatus.value.envConfigured
|
||||
&& !addressCreationSubdomainMatchStatus.value.envEnabled
|
||||
})
|
||||
const subdomainMatchModeOptions = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV,
|
||||
label: t('create_address_subdomain_match_follow_env')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE,
|
||||
label: t('create_address_subdomain_match_force_enable')
|
||||
},
|
||||
{
|
||||
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE,
|
||||
label: t('create_address_subdomain_match_force_disable')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
const showEmailForwardingModal = ref(false)
|
||||
const emailForwardingList = ref([])
|
||||
|
||||
|
||||
const emailForwardingColumns = [
|
||||
{
|
||||
title: t('domain_list'),
|
||||
key: 'domains',
|
||||
render: (row, index) => {
|
||||
return h(NSelect, {
|
||||
value: Array.isArray(row.domains) ? row.domains : [],
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].domains = val
|
||||
},
|
||||
options: openSettings.value?.domains || [],
|
||||
multiple: true,
|
||||
filterable: true,
|
||||
tag: true,
|
||||
placeholder: t('select_domain')
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('source_patterns'),
|
||||
key: 'sourcePatterns',
|
||||
render: (row, index) => {
|
||||
return h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' }, [
|
||||
h(NSelect, {
|
||||
value: Array.isArray(row.sourcePatterns) ? row.sourcePatterns : [],
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].sourcePatterns = val
|
||||
},
|
||||
multiple: true,
|
||||
filterable: true,
|
||||
tag: true,
|
||||
placeholder: t('source_patterns_placeholder')
|
||||
}, {
|
||||
empty: () => h('span', { style: 'color: #999; font-size: 12px;' }, t('manualInputPrompt'))
|
||||
}),
|
||||
h(NRadioGroup, {
|
||||
value: row.sourceMatchMode || 'any',
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].sourceMatchMode = val
|
||||
},
|
||||
size: 'small',
|
||||
style: 'margin-top: 4px;'
|
||||
}, {
|
||||
default: () => [
|
||||
h(NRadio, { value: 'any' }, { default: () => t('match_any') }),
|
||||
h(NRadio, { value: 'all' }, { default: () => t('match_all') })
|
||||
]
|
||||
})
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('forward_address'),
|
||||
key: 'forward',
|
||||
render: (row, index) => {
|
||||
return h(NInput, {
|
||||
value: row.forward,
|
||||
onUpdateValue: (val) => {
|
||||
emailForwardingList.value[index].forward = val
|
||||
},
|
||||
placeholder: 'forward@example.com'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('actions'),
|
||||
key: 'actions',
|
||||
render: (row, index) => {
|
||||
return h('div', { style: 'display: flex; gap: 8px;' }, [
|
||||
h(NPopconfirm, {
|
||||
onPositiveClick: () => {
|
||||
emailForwardingList.value = emailForwardingList.value.filter((_, i) => i !== index)
|
||||
message.success(t('delete_success'))
|
||||
}
|
||||
}, {
|
||||
default: () => t('delete_rule_confirm'),
|
||||
trigger: () => h(NButton, {
|
||||
size: 'small',
|
||||
type: 'error'
|
||||
}, { default: () => t('delete_rule') })
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const openEmailForwardingModal = () => {
|
||||
// 从 emailRuleSettings 转换出列表数据
|
||||
emailForwardingList.value = emailRuleSettings.value.emailForwardingList ?
|
||||
[...emailRuleSettings.value.emailForwardingList] : []
|
||||
showEmailForwardingModal.value = true
|
||||
}
|
||||
|
||||
const addNewEmailForwardingItem = () => {
|
||||
emailForwardingList.value = [
|
||||
...emailForwardingList.value,
|
||||
{
|
||||
domains: [],
|
||||
forward: '',
|
||||
sourcePatterns: [],
|
||||
sourceMatchMode: 'any'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const MAX_REGEX_LENGTH = 200
|
||||
|
||||
const validateForwardingRules = () => {
|
||||
for (let i = 0; i < emailForwardingList.value.length; i++) {
|
||||
const rule = emailForwardingList.value[i]
|
||||
|
||||
// 验证转发地址
|
||||
if (!rule.forward || rule.forward.trim() === '') {
|
||||
message.error(`${t('forward_address_required')} (${t('rule_index')} ${i + 1})`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 验证正则表达式
|
||||
if (rule.sourcePatterns && rule.sourcePatterns.length > 0) {
|
||||
for (const pattern of rule.sourcePatterns) {
|
||||
// 检查长度
|
||||
if (pattern.length > MAX_REGEX_LENGTH) {
|
||||
message.error(`${t('regex_too_long')}: ${pattern.substring(0, 30)}...`)
|
||||
return false
|
||||
}
|
||||
// 检查正则有效性
|
||||
try {
|
||||
new RegExp(pattern, 'i')
|
||||
} catch (e) {
|
||||
message.error(`${t('regex_invalid')}: ${pattern}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const saveEmailForwardingConfig = () => {
|
||||
if (!validateForwardingRules()) {
|
||||
return
|
||||
}
|
||||
emailRuleSettings.value.emailForwardingList = [...emailForwardingList.value]
|
||||
showEmailForwardingModal.value = false
|
||||
}
|
||||
|
||||
const getSubdomainMatchModeByStoredValue = (storedEnabled) => {
|
||||
if (storedEnabled === true) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE
|
||||
}
|
||||
if (storedEnabled === false) {
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE
|
||||
}
|
||||
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV
|
||||
}
|
||||
|
||||
const getSubdomainMatchPayloadValue = (mode) => {
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE) {
|
||||
return true
|
||||
}
|
||||
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fetchData = async ({ suppressErrorMessage = false } = {}) => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/account_settings`)
|
||||
addressBlockList.value = res.blockList || []
|
||||
sendAddressBlockList.value = res.sendBlockList || []
|
||||
verifiedAddressList.value = res.verifiedAddressList || []
|
||||
fromBlockList.value = res.fromBlockList || []
|
||||
noLimitSendAddressList.value = res.noLimitSendAddressList || []
|
||||
emailRuleSettings.value = {
|
||||
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
|
||||
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
|
||||
}
|
||||
addressCreationSubdomainMatchStatus.value = {
|
||||
envConfigured: !!res.addressCreationSubdomainMatchStatus?.envConfigured,
|
||||
envEnabled: !!res.addressCreationSubdomainMatchStatus?.envEnabled,
|
||||
storedEnabled: typeof res.addressCreationSubdomainMatchStatus?.storedEnabled === 'boolean'
|
||||
? res.addressCreationSubdomainMatchStatus.storedEnabled
|
||||
: undefined,
|
||||
effectiveEnabled: !!res.addressCreationSubdomainMatchStatus?.effectiveEnabled
|
||||
}
|
||||
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
|
||||
addressCreationSubdomainMatchStatus.value.storedEnabled
|
||||
)
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
if (!suppressErrorMessage) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
noLimitSendAddressList: noLimitSendAddressList.value || [],
|
||||
emailRuleSettings: emailRuleSettings.value,
|
||||
addressCreationSettings: {
|
||||
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
|
||||
}
|
||||
}
|
||||
await api.fetch(`/admin/account_settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
blockList: addressBlockList.value || [],
|
||||
sendBlockList: sendAddressBlockList.value || [],
|
||||
verifiedAddressList: verifiedAddressList.value || [],
|
||||
fromBlockList: fromBlockList.value || [],
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchData({ suppressErrorMessage: true })
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh account settings after save', error)
|
||||
message.warning(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
try {
|
||||
await fetchData();
|
||||
} catch {
|
||||
// 首次加载失败时,错误提示已经在 fetchData 内部统一处理,这里无需重复提示。
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-alert :show-icon="false" style="margin-bottom: 10px;">
|
||||
{{ t("tip") }}
|
||||
<n-alert :show-icon="false" :bordered="false" type="warning" style="margin-bottom: 10px;">
|
||||
<span>{{ t("tip") }}</span>
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-form-item-row :label="t('address_block_list')">
|
||||
<n-select v-model:value="addressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')" />
|
||||
:placeholder="t('address_block_list_placeholder')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('send_address_block_list')">
|
||||
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
|
||||
:placeholder="t('address_block_list_placeholder')" />
|
||||
:placeholder="t('address_block_list_placeholder')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('noLimitSendAddressList')">
|
||||
<n-select v-model:value="noLimitSendAddressList" filterable multiple tag
|
||||
:placeholder="t('noLimitSendAddressList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('verified_address_list')">
|
||||
<n-select v-model:value="verifiedAddressList" filterable multiple tag
|
||||
:placeholder="t('verified_address_list')" />
|
||||
:placeholder="t('verified_address_list')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('fromBlockList')">
|
||||
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')" />
|
||||
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('block_receive_unknow_address_email')">
|
||||
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('create_address_subdomain_match')">
|
||||
<n-flex vertical style="width: 100%;">
|
||||
<n-radio-group v-model:value="addressCreationSubdomainMatchMode">
|
||||
<n-space vertical size="small">
|
||||
<n-radio v-for="item in subdomainMatchModeOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_tip') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_note') }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
{{ t('create_address_subdomain_match_follow_env_note') }}
|
||||
</n-text>
|
||||
<n-alert v-if="subdomainMatchEnvLocked" type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('create_address_subdomain_match_env_locked') }}
|
||||
</n-alert>
|
||||
</n-flex>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('email_forwarding_config')">
|
||||
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
|
||||
</n-form-item-row>
|
||||
<n-button @click="save" type="primary" block :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 邮件转发配置弹窗 -->
|
||||
<n-modal v-model:show="showEmailForwardingModal" preset="card" :title="t('email_forwarding_config')"
|
||||
style="max-width: 1000px;">
|
||||
<n-space vertical>
|
||||
<n-alert :show-icon="false" :bordered="false" type="warning">
|
||||
<span>{{ t('forwarding_rule_warning') }}</span>
|
||||
<br />
|
||||
<span>{{ t('source_patterns_tip') }}</span>
|
||||
</n-alert>
|
||||
<n-space justify="end">
|
||||
<n-button @click="addNewEmailForwardingItem">{{ t('add') }}</n-button>
|
||||
</n-space>
|
||||
<n-data-table :columns="emailForwardingColumns" :data="emailForwardingList" :bordered="false" striped />
|
||||
<n-space justify="end">
|
||||
<n-button @click="saveEmailForwardingConfig" type="primary">{{ t('save') }}</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
125
frontend/src/views/admin/AiExtractSettings.vue
Normal file
125
frontend/src/views/admin/AiExtractSettings.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
// @ts-ignore
|
||||
import { api } from '../../api'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
title: 'AI Email Extraction Settings',
|
||||
successTip: 'Success',
|
||||
save: 'Save',
|
||||
enableAllowList: 'Enable Address Allowlist',
|
||||
enableAllowListTip: 'When enabled, AI extraction will only process emails sent to addresses in the allowlist',
|
||||
allowList: 'Address Allowlist (Enter address and press Enter, wildcards supported)',
|
||||
allowListTip: "Wildcard * matches any characters, e.g. *{'@'}example.com matches all addresses under example.com domain",
|
||||
manualInputPrompt: 'Type and press Enter to add',
|
||||
disabledTip: 'When disabled, AI extraction will process all email addresses',
|
||||
},
|
||||
zh: {
|
||||
title: 'AI 邮件提取设置',
|
||||
successTip: '成功',
|
||||
save: '保存',
|
||||
enableAllowList: '启用地址白名单',
|
||||
enableAllowListTip: '启用后,AI 提取功能仅对白名单中的邮箱地址生效',
|
||||
allowList: '地址白名单 (请输入地址并回车,支持通配符)',
|
||||
allowListTip: "通配符 * 可匹配任意字符,如 *{'@'}example.com 可匹配 example.com 域名下的所有地址",
|
||||
manualInputPrompt: '输入后按回车键添加',
|
||||
disabledTip: '未启用时,所有邮箱地址都可使用 AI 提取功能',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type AiExtractSettings = {
|
||||
enableAllowList: boolean
|
||||
allowList: string[]
|
||||
}
|
||||
|
||||
const settings = ref<AiExtractSettings>({
|
||||
enableAllowList: false,
|
||||
allowList: []
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch(`/admin/ai_extract/settings`) as AiExtractSettings
|
||||
Object.assign(settings.value, res)
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await api.fetch(`/admin/ai_extract/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(settings.value),
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px; overflow: auto;">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="saveSettings" type="primary">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-form-item-row :label="t('enableAllowList')">
|
||||
<n-switch v-model:value="settings.enableAllowList" :round="false" />
|
||||
</n-form-item-row>
|
||||
|
||||
<n-alert v-if="!settings.enableAllowList" type="info" style="margin-bottom: 16px;">
|
||||
{{ t('disabledTip') }}
|
||||
</n-alert>
|
||||
|
||||
<div v-if="settings.enableAllowList">
|
||||
<n-alert type="warning" style="margin-bottom: 16px;">
|
||||
{{ t('enableAllowListTip') }}
|
||||
</n-alert>
|
||||
|
||||
<n-form-item-row :label="t('allowList')">
|
||||
<n-select v-model:value="settings.allowList" filterable multiple tag
|
||||
:placeholder="t('allowListTip')">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-text depth="3" style="font-size: 12px;">
|
||||
{{ t('allowListTip') }}
|
||||
</n-text>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
@@ -15,10 +15,15 @@ const { t } = useI18n({
|
||||
en: {
|
||||
address: 'Address',
|
||||
enablePrefix: 'If enable Prefix',
|
||||
creatNewEmail: 'Get New Email',
|
||||
creatNewEmail: 'Create New Email',
|
||||
fillInAllFields: 'Please fill in all fields',
|
||||
successTip: 'Success Created',
|
||||
addressCredential: 'Mail Address Credential',
|
||||
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
|
||||
addressPassword: 'Address Password',
|
||||
linkWithAddressCredential: 'Open to auto login email link',
|
||||
enableRandomSubdomain: 'Use Random Subdomain',
|
||||
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
|
||||
},
|
||||
zh: {
|
||||
address: '地址',
|
||||
@@ -27,15 +32,36 @@ const { t } = useI18n({
|
||||
fillInAllFields: '请填写完整信息',
|
||||
successTip: '创建成功',
|
||||
addressCredential: '邮箱地址凭证',
|
||||
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
|
||||
addressPassword: '地址密码',
|
||||
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
|
||||
enableRandomSubdomain: '启用随机子域名',
|
||||
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
}
|
||||
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value)
|
||||
})
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const newEmail = async () => {
|
||||
if (!emailName.value || !emailDomain.value) {
|
||||
@@ -47,11 +73,14 @@ const newEmail = async () => {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
enableRandomSubdomain: enableRandomSubdomain.value,
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
})
|
||||
})
|
||||
result.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
createdAddress.value = res["address"] || '';
|
||||
message.success(t('successTip'))
|
||||
showReultModal.value = true
|
||||
} catch (error) {
|
||||
@@ -59,6 +88,10 @@ const newEmail = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getUrlWithJwt = () => {
|
||||
return `${window.location.origin}/?jwt=${result.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (openSettings.prefix) {
|
||||
enablePrefix.value = true
|
||||
@@ -70,14 +103,29 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
|
||||
<p>{{ t('addressCredential') }}</p>
|
||||
<n-card :bordered="false" embedded>
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card embedded>
|
||||
<b>{{ result }}</b>
|
||||
</n-card>
|
||||
<n-card embedded v-if="addressPassword">
|
||||
<p><b>{{ createdAddress }}</b></p>
|
||||
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
|
||||
</n-card>
|
||||
<n-card embedded>
|
||||
<n-collapse>
|
||||
<n-collapse-item :title='t("linkWithAddressCredential")'>
|
||||
<n-card embedded>
|
||||
<b>{{ getUrlWithJwt() }}</b>
|
||||
</n-card>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
<n-card :bordered="false" embedded style="max-width: 600px;">
|
||||
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
|
||||
<n-checkbox v-model:checked="enablePrefix" />
|
||||
<n-switch v-model:value="enablePrefix" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('address')">
|
||||
<n-input-group>
|
||||
@@ -90,6 +138,14 @@ onMounted(async () => {
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
{{ t('creatNewEmail') }}
|
||||
</n-button>
|
||||
|
||||
126
frontend/src/views/admin/DatabaseManager.vue
Normal file
126
frontend/src/views/admin/DatabaseManager.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { CleaningServicesFilled } from '@vicons/material'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { init } from 'vooks/lib/on-fonts-ready';
|
||||
|
||||
const message = useMessage()
|
||||
const dbVersionData = ref({
|
||||
need_initialization: false,
|
||||
need_migration: false,
|
||||
current_db_version: '',
|
||||
code_db_version: ''
|
||||
})
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
need_initialization_tip: 'Database initialization is required. Please initialize the database.',
|
||||
need_migration_tip: 'Database migration is required. Please migrate the database.',
|
||||
current_db_version: 'Current DB Version',
|
||||
code_db_version: 'Code Needed DB Version',
|
||||
init: 'Initialize Database',
|
||||
migration: 'Migrate Database',
|
||||
initializationSuccess: 'Database initialized successfully',
|
||||
migrationSuccess: 'Database migrated successfully',
|
||||
},
|
||||
zh: {
|
||||
need_initialization_tip: '需要初始化数据库,请初始化数据库',
|
||||
need_migration_tip: '需要迁移数据库,请迁移数据库',
|
||||
current_db_version: '当前数据库版本',
|
||||
code_db_version: '需要的数据库版本',
|
||||
init: '初始化数据库',
|
||||
migration: '升级数据库 Schema',
|
||||
initializationSuccess: '数据库初始化成功',
|
||||
migrationSuccess: '数据库升级成功',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch('/admin/db_version');
|
||||
if (res) Object.assign(dbVersionData.value, res);
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const initialization = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/db_initialize', {
|
||||
method: 'POST'
|
||||
});
|
||||
await fetchData();
|
||||
message.success(t('initializationSuccess'));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const migration = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/db_migration', {
|
||||
method: 'POST'
|
||||
});
|
||||
await fetchData();
|
||||
message.success(t('migrationSuccess'));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-alert v-if="dbVersionData.need_initialization" type="warning" :show-icon="false" :bordered="false">
|
||||
<span>{{ t('need_initialization_tip') }}</span>
|
||||
<n-button @click="initialization" type="primary" secondary block :loading="loading">
|
||||
{{ t('init') }}
|
||||
</n-button>
|
||||
</n-alert>
|
||||
<n-alert v-if="dbVersionData.need_migration" type="warning" :show-icon="false" :bordered="false">
|
||||
<span>{{ t('need_migration_tip') }}</span>
|
||||
<n-button @click="migration" type="primary" secondary block :loading="loading">
|
||||
{{ t('migration') }}
|
||||
</n-button>
|
||||
</n-alert>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
<span>
|
||||
{{ t('current_db_version') }}: {{ dbVersionData.current_db_version || "unknown" }},
|
||||
{{ t('code_db_version') }}: {{ dbVersionData.code_db_version }}
|
||||
</span>
|
||||
</n-alert>
|
||||
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
268
frontend/src/views/admin/IpBlacklistSettings.vue
Normal file
268
frontend/src/views/admin/IpBlacklistSettings.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
|
||||
const { loading } = useGlobalState()
|
||||
const message = useMessage()
|
||||
|
||||
const { t } = useI18n({
|
||||
messages: {
|
||||
en: {
|
||||
title: 'IP Blacklist Settings',
|
||||
manualInputPrompt: 'Type pattern and press Enter to add',
|
||||
save: 'Save',
|
||||
successTip: 'Save Success',
|
||||
enable_ip_blacklist: 'Enable IP Blacklist',
|
||||
enable_tip: 'Block IPs matching blacklist patterns from accessing rate-limited APIs',
|
||||
enable_ip_whitelist: 'Enable IP Whitelist (Strict)',
|
||||
enable_whitelist_tip: 'Strict mode: ONLY IPs matching the whitelist can access rate-limited APIs. All other IPs will be denied.',
|
||||
ip_whitelist: 'IP Whitelist Patterns',
|
||||
ip_whitelist_placeholder: 'Exact IP (e.g., 1.2.3.4) or anchored regex (e.g., ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP Whitelist: Strict allowlist — plain entries must be EXACT IP matches (no substring). Use anchored regex (^...$) for ranges. Whitelisted IPs skip blacklist checks.',
|
||||
whitelist_empty_warning: 'IP whitelist is enabled but the list is empty. This is ignored by the server to prevent lockout. Please add at least one entry before enabling.',
|
||||
ip_blacklist: 'IP Blacklist Patterns',
|
||||
ip_blacklist_placeholder: 'Enter pattern (e.g., 192.168.1 or ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN Organization Blacklist',
|
||||
asn_blacklist_placeholder: 'Enter ASN organization (e.g., Google, Amazon)',
|
||||
fingerprint_blacklist: 'Browser Fingerprint Blacklist',
|
||||
fingerprint_blacklist_placeholder: 'Enter fingerprint ID (e.g., a1b2c3d4e5f6g7h8)',
|
||||
tip_ip: 'IP Blacklist: Supports text matching (e.g., "192.168.1") or regex (e.g., "^10\\.0\\.0\\.5$").',
|
||||
tip_asn: 'ASN Organization: Block by ISP/provider. Case-insensitive text matching or regex.',
|
||||
tip_fingerprint: 'Browser Fingerprint: Block by browser fingerprint. Supports exact matching or regex patterns.',
|
||||
tip_daily_limit: 'Daily Limit: Restrict the maximum number of requests per IP address per day (1-1000000).',
|
||||
tip_scope: 'Applies to: Create Address, Send Mail, External Send Mail API, User Registration, Verify Code',
|
||||
enable_daily_limit: 'Enable Daily Request Limit',
|
||||
enable_daily_limit_tip: 'Limit the number of API requests per IP address per day',
|
||||
daily_request_limit: 'Daily Request Limit',
|
||||
daily_request_limit_placeholder: 'Enter limit (e.g., 1000)',
|
||||
},
|
||||
zh: {
|
||||
title: 'IP 黑名单设置',
|
||||
manualInputPrompt: '输入匹配模式后按回车键添加',
|
||||
save: '保存',
|
||||
successTip: '保存成功',
|
||||
enable_ip_blacklist: '启用 IP 黑名单',
|
||||
enable_tip: '阻止匹配黑名单的 IP 访问限流 API',
|
||||
enable_ip_whitelist: '启用 IP 白名单(严格模式)',
|
||||
enable_whitelist_tip: '严格模式:仅允许匹配白名单的 IP 访问限流 API,其他所有 IP 将被拒绝',
|
||||
ip_whitelist: 'IP 白名单匹配模式',
|
||||
ip_whitelist_placeholder: '精确 IP(如 1.2.3.4)或锚定正则(如 ^192\\.168\\.1\\.\\d+$)',
|
||||
tip_whitelist: 'IP 白名单: 严格放行名单——纯文本必须是精确 IP(不支持子串匹配), 批量放行请用锚定正则 ^...$. 命中白名单的 IP 将跳过黑名单检查.',
|
||||
whitelist_empty_warning: 'IP 白名单已启用但列表为空,服务端将忽略该开关以防止锁死。请先添加至少一条白名单条目再启用。',
|
||||
ip_blacklist: 'IP 黑名单匹配模式',
|
||||
ip_blacklist_placeholder: '输入匹配模式(例如:192.168.1 或 ^10\\.0\\.0\\.5$)',
|
||||
asn_blacklist: 'ASN 组织(运营商)黑名单',
|
||||
asn_blacklist_placeholder: '输入 ASN 组织名称(例如:Google, Amazon)',
|
||||
fingerprint_blacklist: '浏览器指纹黑名单',
|
||||
fingerprint_blacklist_placeholder: '输入指纹 ID(例如:a1b2c3d4e5f6g7h8)',
|
||||
tip_ip: 'IP 黑名单:支持文本匹配(如 "192.168.1")或正则表达式(如 "^10\\.0\\.0\\.5$")。',
|
||||
tip_asn: 'ASN 组织:根据运营商/ISP 拉黑。支持不区分大小写的文本匹配或正则表达式。',
|
||||
tip_fingerprint: '浏览器指纹:根据浏览器指纹拉黑。支持完全匹配或正则表达式。',
|
||||
tip_daily_limit: '每日限流:限制单个 IP 地址每天最多请求次数(1-1000000)。',
|
||||
tip_scope: '作用范围:创建邮箱地址、发送邮件、外部发送邮件 API、用户注册、验证码验证',
|
||||
enable_daily_limit: '启用每日请求限流',
|
||||
enable_daily_limit_tip: '限制每个 IP 地址每天的 API 请求次数',
|
||||
daily_request_limit: '每日请求次数上限',
|
||||
daily_request_limit_placeholder: '输入限制次数(例如:1000)',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const enabled = ref(false)
|
||||
const ipBlacklist = ref([])
|
||||
const asnBlacklist = ref([])
|
||||
const fingerprintBlacklist = ref([])
|
||||
const enableWhitelist = ref(false)
|
||||
const ipWhitelist = ref([])
|
||||
const enableDailyLimit = ref(false)
|
||||
const dailyRequestLimit = ref(1000)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await api.fetch(`/admin/ip_blacklist/settings`)
|
||||
enabled.value = res.enabled || false
|
||||
ipBlacklist.value = res.blacklist || []
|
||||
asnBlacklist.value = res.asnBlacklist || []
|
||||
fingerprintBlacklist.value = res.fingerprintBlacklist || []
|
||||
enableWhitelist.value = res.enableWhitelist || false
|
||||
ipWhitelist.value = res.whitelist || []
|
||||
enableDailyLimit.value = res.enableDailyLimit || false
|
||||
dailyRequestLimit.value = res.dailyRequestLimit || 1000
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (enableWhitelist.value && (!ipWhitelist.value || ipWhitelist.value.length === 0)) {
|
||||
message.warning(t('whitelist_empty_warning'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
loading.value = true
|
||||
await api.fetch(`/admin/ip_blacklist/settings`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enabled: enabled.value,
|
||||
blacklist: ipBlacklist.value || [],
|
||||
asnBlacklist: asnBlacklist.value || [],
|
||||
fingerprintBlacklist: fingerprintBlacklist.value || [],
|
||||
enableWhitelist: enableWhitelist.value,
|
||||
whitelist: ipWhitelist.value || [],
|
||||
enableDailyLimit: enableDailyLimit.value,
|
||||
dailyRequestLimit: dailyRequestLimit.value
|
||||
})
|
||||
})
|
||||
message.success(t('successTip'))
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :title="t('title')" :bordered="false" embedded style="max-width: 800px;">
|
||||
<template #header-extra>
|
||||
<n-button @click="save" type="primary" :loading="loading">
|
||||
{{ t('save') }}
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
<n-space vertical :size="20">
|
||||
<n-alert :show-icon="false" :bordered="false" type="info">
|
||||
<div style="line-height: 1.8;">
|
||||
<div><strong>{{ t("tip_scope") }}</strong></div>
|
||||
<div>• {{ t("tip_whitelist") }}</div>
|
||||
<div>• {{ t("tip_ip") }}</div>
|
||||
<div>• {{ t("tip_asn") }}</div>
|
||||
<div>• {{ t("tip_fingerprint") }}</div>
|
||||
<div>• {{ t("tip_daily_limit") }}</div>
|
||||
</div>
|
||||
</n-alert>
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_whitelist')">
|
||||
<n-switch v-model:value="enableWhitelist" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_whitelist_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('ip_whitelist')">
|
||||
<n-select
|
||||
v-model:value="ipWhitelist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('ip_whitelist_placeholder')"
|
||||
:disabled="!enableWhitelist">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-form-item-row :label="t('enable_ip_blacklist')">
|
||||
<n-switch v-model:value="enabled" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('ip_blacklist')">
|
||||
<n-select
|
||||
v-model:value="ipBlacklist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('ip_blacklist_placeholder')"
|
||||
:disabled="!enabled">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('asn_blacklist')">
|
||||
<n-select
|
||||
v-model:value="asnBlacklist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('asn_blacklist_placeholder')"
|
||||
:disabled="!enabled">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('fingerprint_blacklist')">
|
||||
<n-select
|
||||
v-model:value="fingerprintBlacklist"
|
||||
filterable
|
||||
multiple
|
||||
tag
|
||||
:placeholder="t('fingerprint_blacklist_placeholder')"
|
||||
:disabled="!enabled">
|
||||
<template #empty>
|
||||
<n-text depth="3">
|
||||
{{ t('manualInputPrompt') }}
|
||||
</n-text>
|
||||
</template>
|
||||
</n-select>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-form-item-row :label="t('enable_daily_limit')">
|
||||
<n-switch v-model:value="enableDailyLimit" :round="false" />
|
||||
<n-text depth="3" style="margin-left: 10px; font-size: 12px;">
|
||||
{{ t('enable_daily_limit_tip') }}
|
||||
</n-text>
|
||||
</n-form-item-row>
|
||||
|
||||
<n-form-item-row :label="t('daily_request_limit')">
|
||||
<n-input-number
|
||||
v-model:value="dailyRequestLimit"
|
||||
:min="1"
|
||||
:max="1000000"
|
||||
:placeholder="t('daily_request_limit_placeholder')"
|
||||
:disabled="!enableDailyLimit"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</n-form-item-row>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.center {
|
||||
display: flex;
|
||||
text-align: left;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
margin: 20px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user