mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-09 17:36:58 +08:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a4a657aa | ||
|
|
f88852a353 | ||
|
|
0a0a56e653 | ||
|
|
4ddd502a60 | ||
|
|
406d1956b8 | ||
|
|
66ab5a05df | ||
|
|
8e1bfb8af2 | ||
|
|
fc363cc9c5 | ||
|
|
806ec1aeab | ||
|
|
3c505db9d0 | ||
|
|
5fd181d9c0 | ||
|
|
70206c61ef | ||
|
|
f92b059aac | ||
|
|
5dbb6107dd | ||
|
|
dccca92928 | ||
|
|
005d74bfde | ||
|
|
aeb2ac687d | ||
|
|
108b8ef4ac | ||
|
|
2762741226 | ||
|
|
3ffa16234f | ||
|
|
3bcc0c19ce | ||
|
|
a3c62de42f | ||
|
|
624fc9bb96 | ||
|
|
12152fc893 | ||
|
|
a09ede8944 | ||
|
|
f9281818e9 | ||
|
|
5553c6484a | ||
|
|
d04c1a865d | ||
|
|
116ddc7324 | ||
|
|
2dcbad40ad | ||
|
|
95badf5aed | ||
|
|
b3666c0900 | ||
|
|
e499211197 | ||
|
|
342fe22e4f | ||
|
|
8c883b269f | ||
|
|
4c1e593d07 | ||
|
|
7eaa3b3b8e | ||
|
|
0581632b59 | ||
|
|
4ce22ef249 | ||
|
|
99b332345b | ||
|
|
565bb839db | ||
|
|
dbd1f8706d | ||
|
|
3f1d800e90 | ||
|
|
70b30c2494 | ||
|
|
1a1dd720c8 | ||
|
|
2d501d82cf | ||
|
|
7c57592742 | ||
|
|
41105ed803 | ||
|
|
c924b71a5c | ||
|
|
4f0b44de2e | ||
|
|
f6fcbe793c | ||
|
|
05a8ebb590 | ||
|
|
b7718100c5 | ||
|
|
bf786947e3 | ||
|
|
cfb31807f1 | ||
|
|
308fbe2f9a | ||
|
|
44b29aa646 | ||
|
|
2221342560 | ||
|
|
add0124cfd | ||
|
|
8324b133fb | ||
|
|
74c8e8f7e4 | ||
|
|
2df4de22d9 | ||
|
|
437db7c050 | ||
|
|
dd294037ab | ||
|
|
a9e2c89246 | ||
|
|
72bbfe8fd6 | ||
|
|
796a5e4ac5 | ||
|
|
347be5c762 | ||
|
|
0b17953097 | ||
|
|
ec3e9f0b1f | ||
|
|
fb6e77fded | ||
|
|
c703082391 | ||
|
|
4e01b4e26f | ||
|
|
7d68cbdffa | ||
|
|
6d43c107d8 | ||
|
|
46fbb4c099 | ||
|
|
c9698d03f1 | ||
|
|
5f955ccca6 | ||
|
|
7e7f824f88 | ||
|
|
eb62c37e02 | ||
|
|
1e50bb0933 | ||
|
|
063b6be2b1 | ||
|
|
e94ff52add |
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../.claude/skills
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../skills/cf-temp-mail-agent-mail
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: cf-temp-mail-upgrade-dependencies
|
name: cf-temp-mail-upgrade-dependencies
|
||||||
description: Upgrade npm dependencies across all sub-packages of the project. Use when the user asks to upgrade/update dependencies, bump deps, refresh lockfiles, or update wrangler. Runs pnpm upgrades on frontend/, worker/, pages/, and vitepress-docs/.
|
description: Upgrade npm dependencies across all sub-packages of the project. Use when the user asks to upgrade/update dependencies, bump deps, refresh lockfiles, or update wrangler. Runs pnpm upgrades on frontend/, worker/, pages/, and vitepress-docs/, plus npm upgrades on e2e/.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Upgrade Dependencies
|
# Upgrade Dependencies
|
||||||
@@ -23,18 +23,21 @@ The script runs the following in order:
|
|||||||
| `worker/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
| `worker/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||||
| `pages/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
| `pages/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||||
| `vitepress-docs/` | `pnpm up --latest` + `pnpm add -D wrangler@latest` |
|
| `vitepress-docs/` | `pnpm up --latest` + `pnpm add -D wrangler@latest` |
|
||||||
|
| `e2e/` | `npx --yes npm-check-updates@23.0.0 --upgrade` + `npm install` + Playwright image validation |
|
||||||
|
|
||||||
Note: `vitepress-docs/` uses `--latest` (crosses semver ranges); other packages upgrade within ranges only.
|
Note: `vitepress-docs/` and `e2e/` upgrade to the latest versions and may cross semver ranges; other packages upgrade within ranges only. `e2e/` uses npm because it has a `package-lock.json`.
|
||||||
|
|
||||||
## Post-upgrade checklist
|
## Post-upgrade checklist
|
||||||
|
|
||||||
1. Inspect `git diff` on `package.json` / `pnpm-lock.yaml` files for reasonable changes.
|
1. Inspect `git diff` on `package.json`, `pnpm-lock.yaml`, and `package-lock.json` files for reasonable changes.
|
||||||
2. Verify builds in each sub-package:
|
2. Verify builds in each sub-package:
|
||||||
- `cd frontend && pnpm build`
|
- `cd frontend && pnpm build`
|
||||||
- `cd worker && pnpm build && pnpm lint`
|
- `cd worker && pnpm build && pnpm lint`
|
||||||
- `cd vitepress-docs && pnpm build`
|
- `cd vitepress-docs && pnpm build`
|
||||||
3. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
|
- `cd e2e && npm test`
|
||||||
4. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
|
3. If Playwright changed, keep `e2e/Dockerfile.e2e` on the matching Playwright image version.
|
||||||
|
4. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
|
||||||
|
5. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
|
||||||
|
|
||||||
## Do NOT
|
## Do NOT
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ Upgrade the version number of the cloudflare_temp_email project.
|
|||||||
4. Update the `VERSION` constant in `worker/src/constants.ts`.
|
4. Update the `VERSION` constant in `worker/src/constants.ts`.
|
||||||
5. Insert a new version placeholder at the top of `CHANGELOG.md`.
|
5. Insert a new version placeholder at the top of `CHANGELOG.md`.
|
||||||
6. Insert a new version placeholder at the top of `CHANGELOG_EN.md`.
|
6. Insert a new version placeholder at the top of `CHANGELOG_EN.md`.
|
||||||
|
7. Rename the previous version heading in both changelogs from `## v{OLD_VERSION}(main)` to `## v{OLD_VERSION}` so only the new active development version keeps `(main)`.
|
||||||
|
|
||||||
## CHANGELOG format
|
## CHANGELOG format
|
||||||
|
|
||||||
@@ -44,7 +45,14 @@ In `CHANGELOG.md`, insert before the existing `## v{OLD_VERSION}(main)` line (i.
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`CHANGELOG_EN.md` uses the same format.
|
After inserting the new placeholder, update the old heading:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-## v{OLD_VERSION}(main)
|
||||||
|
+## v{OLD_VERSION}
|
||||||
|
```
|
||||||
|
|
||||||
|
`CHANGELOG_EN.md` uses the same format and must receive the same old-heading update.
|
||||||
|
|
||||||
## Commit message format
|
## Commit message format
|
||||||
|
|
||||||
@@ -53,4 +61,5 @@ feat: upgrade version to v{VERSION}
|
|||||||
|
|
||||||
- Update version number to {VERSION} in all package.json files
|
- Update version number to {VERSION} in all package.json files
|
||||||
- Add v{VERSION} placeholder in CHANGELOG.md
|
- Add v{VERSION} placeholder in CHANGELOG.md
|
||||||
|
- Move (main) marker from v{OLD_VERSION} to v{VERSION}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -40,11 +40,17 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
run: |
|
run: |
|
||||||
cd vitepress-docs/
|
cd vitepress-docs/
|
||||||
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
||||||
pnpm install --no-frozen-lockfile
|
pnpm install --no-frozen-lockfile
|
||||||
TAG_NAME=$(gh release view --json tagName --jq '.tagName')
|
if TAG_NAME=$(gh release view --json tagName --jq '.tagName' 2>/dev/null); then
|
||||||
|
echo "Using release tag $TAG_NAME"
|
||||||
|
else
|
||||||
|
TAG_NAME="${WORKFLOW_RUN_HEAD_BRANCH:-${GITHUB_REF_NAME:-main}}"
|
||||||
|
echo "No GitHub release found for this repo; fallback TAG_NAME=$TAG_NAME"
|
||||||
|
fi
|
||||||
echo "Deploying docs for tag $TAG_NAME"
|
echo "Deploying docs for tag $TAG_NAME"
|
||||||
export TAG_NAME
|
export TAG_NAME
|
||||||
pnpm run deploy
|
pnpm run deploy
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
|
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||||
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||||
CONFIG.MODEL: "gpt-4o"
|
CONFIG.MODEL: "gpt-5.4-nano"
|
||||||
CONFIG.MODEL_TURBO: "gpt-4o"
|
CONFIG.MODEL_TURBO: "gpt-5.4-nano"
|
||||||
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v5
|
- uses: pnpm/action-setup@v5
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|||||||
@@ -142,3 +142,6 @@ pnpm-lock.yaml
|
|||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
e2e/playwright-report/
|
e2e/playwright-report/
|
||||||
e2e/.e2e-pids
|
e2e/.e2e-pids
|
||||||
|
|
||||||
|
# SMTP/IMAP proxy persisted IMAP flags (local dev / bare-metal runs)
|
||||||
|
smtp_proxy_server/data/
|
||||||
|
|||||||
+132
-5
@@ -6,15 +6,142 @@
|
|||||||
<a href="CHANGELOG_EN.md">English</a>
|
<a href="CHANGELOG_EN.md">English</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## v1.8.0(main)
|
## v1.12.0(main)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
|
||||||
|
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
|
||||||
|
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
|
||||||
|
- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态
|
||||||
|
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||||
|
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126)
|
||||||
|
- feat: |Admin API| 新增 `ADMIN_API_IP_WHITELIST`,可按来源 IP 限制所有管理接口访问
|
||||||
|
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分站点认证错误以避免重复登录,并将 E2E 测试接口移出生产代码
|
||||||
|
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性
|
||||||
|
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
|
||||||
|
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||||
|
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||||
|
- fix: |用户发信| 用户地址发信接口支持角色无限额度
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
## v1.7.0(main)
|
- fix: |Frontend| 统一邮箱、邮箱地址、用户账号与管理后台的核心界面术语,并补齐附加语言缺失的邮件状态与远程图片文案(issue #1129)
|
||||||
|
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||||
|
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||||
|
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||||
|
|
||||||
|
## v1.11.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |Frontend| 在随机子域名允许范围内新增普通、随机和自定义子域名模式选择(issue #1108)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Frontend| 将邮箱主页的账户设置、删除账户等表述更正为邮箱地址操作,并明确发信权限与额度按当前邮箱地址独立管理
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- docs: |发送邮件| 补充用户账号、邮箱地址和发信权限的概念区别及按当前地址申请权限的操作说明
|
||||||
|
|
||||||
|
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103)
|
||||||
|
|
||||||
|
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103)
|
||||||
|
|
||||||
|
- feat: |Worker| 邮件、发件箱及按创建/活跃时间清理地址时改为分批处理,默认每次最多 3000 条并支持通过 `CLEANUP_BATCH_SIZE` 调整(上限 5000),减少单次扫描和删除量(issue #1103)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试
|
||||||
|
- fix: |E2E| 新增清理批次上限、后续批次继续执行、保留未过期数据及地址关联数据清理测试
|
||||||
|
|
||||||
|
## v1.10.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |Admin| 新增 `GET /admin/mails/:id` 接口,支持管理员按邮件 ID 跨邮箱读取单封邮件,并兼容 gzip 压缩存储(issue #1096)
|
||||||
|
- feat: |Frontend| 邮箱新增「邮箱全宽列表视图」开关(在外观设置中控制),开启后默认全宽列表展示邮件标题与正文预览,点击单封邮件再展开为双栏,再次点击同一封邮件可回到列表视图;多选模式下点击邮件会同步切换勾选状态与右侧预览,并禁用同邮件点击收回列表,展开时双栏左侧列表宽度仍遵循「邮箱双栏视图左侧列表宽度占比」配置;默认关闭,保留原有双栏行为
|
||||||
|
- feat: |Frontend| 邮箱全宽列表视图新增「正文预览行数」配置(在外观设置中控制),可设置邮件正文预览的最大行数,默认 2 行,0 表示关闭预览
|
||||||
|
- feat: |Frontend| 外观设置新增「自动加载邮件正文中的外部图片」开关,关闭后邮件预览(含全屏视图)会先经 DOMPurify 消毒,并以白名单策略处理所有可能发起请求的位置:仅保留可证明为本地的引用(`cid:`、`data:image/`、`blob:` 与站内相对路径),其余一律阻断;`base`、`meta`、`script`、`link`、`iframe`、`object`、`embed`、`noscript` 等会自行取用资源或改变解析基准的元素在此模式下移除,`<style>` 保留但其中 `url()`、`image-set()`、`@import` 的远端引用会被替换。正文上方显示已阻断资源数量的提示条,可一键按封加载;默认保持开启,行为与此前一致(issue #1073)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `<a>` 与 `<area>` 的外部导航链接,并阻断通过 CSS 转义函数名或 at-rule 绕过远程资源过滤的情况
|
||||||
|
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
|
||||||
|
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测:SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
|
||||||
|
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
|
||||||
|
- fix: |AI 提取| 强化提示词,要求 AI 保持邮件原始链接域名,避免小模型改写验证链接域名导致错误跳转(issue #1072)
|
||||||
|
- fix: |AI 提取| HTML-only 邮件在发送给 Workers AI 前会先压缩为可读文本,避免样式模板过长导致验证码位于 4000 字截断之后而无法识别
|
||||||
|
- fix: |Frontend| 移动端 Header 增加页头内边距,避免标题、菜单按钮与屏幕边缘过近
|
||||||
|
- fix: |IMAP 代理| 修复 IMAP `STORE` 无法真正标记邮件已读的问题:邮件不再硬编码为 `\Seen`,且 `SimpleMailbox` 的 flags 变更现持久化到本地 SQLite(新增 `imap_flag_db_path` 配置),使已读/未读状态可在客户端断线重连(如 Thunderbird 轮询)后保留,而非每次新建连接即丢失(issue #1074)
|
||||||
|
- fix: |IMAP 代理| 修复 `SEARCH UNSEEN` 返回全部邮件的问题:`SimpleMailbox.search()` 现按持久化的 flags 计算 `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` 及其否定形式,多个条件按 AND 组合;无法识别的检索条件仍沿用原有行为返回全部邮件
|
||||||
|
- fix: |IMAP 代理| 修复取信不会自动标记已读的问题:`BODY[...]`、`RFC822`、`RFC822.TEXT` 取信现按 RFC 3501 自动置 `\Seen`,而 `BODY.PEEK[...]`、`RFC822.HEADER` 及仅取元数据(如 `FLAGS`)不会
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: |Worker| 新增 junk_mail_policy 回归测试(issue #1084):覆盖 SPF/DKIM/DMARC 的 `none`/`neutral` 按认证方法不存在处理、明确 `fail` 仍被拒收,以及 `JUNK_MAIL_FORCE_PASS_LIST` 仅接受明确 `pass`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- docs: |README| 新增完整日文 README,并在中文和英文 README 中添加日文导航链接
|
||||||
|
- feat: |Frontend| 「邮箱双栏视图左侧列表宽度占比」最小值由 0.25 放宽至 0,左侧列表可完全折叠使正文近乎全屏,刻度增加 0 点;收件箱与发件箱的双栏拆分同步生效,并优化外观设置文案以明确该比例控制左侧邮件列表宽度
|
||||||
|
|
||||||
|
## v1.9.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |AI 识别| 未配置 Workers AI 绑定时,自动回退到内置正则提取验证码(支持中英日韩,并排除年份与 `YYYYMMDD` 日期误判),让无 Workers AI 的自部署用户也能在 Telegram 推送与 Webhook 中拿到验证码
|
||||||
|
- feat: |Telegram| Telegram 新邮件推送与 `/mails` 历史邮件查看支持展示 AI 提取结果,包含验证码、验证链接、服务链接、订阅链接等关键信息
|
||||||
|
- feat: |Webhook| 邮件 Webhook 模板支持填充 AI 提取结果占位符,包括 `aiExtractType`、`aiExtractResult`、`aiExtractResultText`
|
||||||
|
- feat: |Frontend| 新增 `DISABLE_SHOW_GITHUB_FOR_USER` 配置,可仅对普通用户隐藏 Header 的 GitHub/版本入口,admin 仍可见(issue #1041)
|
||||||
|
- feat: |Frontend| 将邮箱地址凭证弹窗升级为“地址凭证与连接方式”,复用普通用户与 admin 创建邮箱结果弹窗;支持通过 `ENABLE_AGENT_EMAIL_INFO` 展示 AI Agent 接入信息,并通过 `SMTP_IMAP_PROXY_CONFIG` 展示 SMTP/IMAP 客户端连接信息
|
||||||
|
- docs: |随机子域名| 在前端“启用随机子域名”提示与 `subdomain` / `worker-vars` 文档(中英)中明确说明:要让 `name@<随机>.abc.com` 真正收到邮件,必须在基础域名 DNS 中为 `*` 子域添加通配 MX 记录,Email Routing 子域不继承父域配置(issue #1035)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码
|
||||||
|
- fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段
|
||||||
|
- fix: |Address| 统一规范化配置域名、收件地址域名与前缀的空白和大小写,覆盖 `DOMAINS`、`DEFAULT_DOMAINS`、`USER_ROLES.domains`、随机子域名、转发规则、SMTP 与 `SEND_MAIL` 域名匹配,保留转发规则空域名 catch-all 行为,并明确空 `DEFAULT_DOMAINS` / 角色域名回退到 `DOMAINS` 的行为,避免大小写配置或入站收件域名导致创建、收件、转发或发信失败(issue #926)
|
||||||
|
- fix: |AI 提取| 将 AI 邮件识别默认 Workers AI 模型切换为支持 JSON Mode 且未弃用的 `@cf/meta/llama-3.1-8b-instruct-fast`,并在文档中补充 `@cf/zai-org/glm-4.7-flash` 结构化输出兼容性提示(issue #1029)
|
||||||
|
- fix: |CI| 将 GitHub Actions 与 e2e Docker 镜像统一升级到 Node.js 24,适配 Wrangler 4.90.0 的运行时要求
|
||||||
|
- fix: |Frontend| 修复 iOS Safari 点击输入框时因移动端表单控件字号过小导致页面自动放大的问题
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
## v1.8.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |Frontend| 前端新增 6 国语言支持(`zh` / `en` / `es` / `pt-BR` / `ja` / `de`),默认语言保持为 `zh`;无 locale 前缀路由(如 `/`、`/user`)默认使用中文渲染,同时会记录浏览器语言作为语言偏好。用户手动切换后会持久化语言偏好,并保持当前页面路径、查询参数与 canonical locale URL 一致
|
||||||
|
- feat: |API| 新增服务端解析邮件接口 `/api/parsed_mails` 与 `/api/parsed_mail/:id`,直接返回 `sender` / `subject` / `text` / `html` / `attachments` 元信息(复用 `commonParseMail`),AI agent 侧不再需要引入 MIME 解析器
|
||||||
|
- feat: |Skill| 新增仓库内置只读 skill `cf-temp-mail-agent-mail`(`skills/cf-temp-mail-agent-mail/`),让 OpenClaw / Codex / Cursor 等 AI agent 凭用户提供的 Address JWT + API 地址读取邮箱、轮询验证码,绕开创建邮箱时的 Turnstile 人机验证;可通过 `npx degit dreamhunter2333/cloudflare_temp_email/skills/cf-temp-mail-agent-mail` 安装
|
||||||
|
- docs: |文档| 新增"AI Agent 使用邮箱"文档(`guide/feature/agent-email`),说明 `parsed_mail` API 用法,并在 parsed API 不可用时给出对齐前端的 `mail-parser-wasm` + `postal-mime` 本地解析回退方案
|
||||||
|
- docs: |文档| 在 `quick-start` / `worker-vars` / `email-routing` 三个入口文档(中英文)显式补充"域名是部署前提条件"提示,强调需先在 Cloudflare 启用 Email Routing 并下发邮件 DNS 记录、Worker 部署后再绑定 Catch-all,子域名需单独启用,避免用户在没有可用域名时直接开始部署却收不到邮件(issue #1004)
|
||||||
|
- docs: |部署排障| 优化近期 issue 暴露的 UI 部署与升级排障文档:补充 `nodejs_compat`、D1 绑定名必须为 `DB`、`/open_api/settings` 校验、后端 API 地址填写、Cloudflare 安全挑战导致 `Network Error`、D1 容量上限与 Cron Trigger 自动清理、GitHub OAuth 公开邮箱、admin 管理口令与用户账号区别、随机二级域名 API 需传 `enableRandomSubdomain` 等说明;同时将帮助/FAQ 菜单移动到核心配置之后,提升可见性
|
||||||
|
- docs: |文档| 补充重新创建旧邮箱提示地址已存在时的处理方式,并完善 GitHub Actions 自动更新配合 Page Functions 转发后端请求的 workflow 说明(issues #947 #654)
|
||||||
|
- docs: |OAuth2| 补充 GitHub 私密邮箱登录配置,说明可使用 `https://api.github.com/user/emails`、JSONPath 邮箱字段和 `user:email` scope 获取主邮箱(issue #655)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Frontend| 收窄地址管理相关弹窗宽度,并让地址表格在弹窗内部横向滚动,避免多地址场景撑宽弹窗
|
||||||
|
- fix: |Frontend| 修复 `/open_api/settings` 未返回 `domains` 数组时前端设置初始化直接调用 `map()` 报 `undefined` 错误的问题,统一按空数组兜底处理
|
||||||
|
- fix: |Frontend| 修复前端在 `jwt` / `auth` / `adminAuth` 等 localStorage 凭据为空字符串、字面量 `"undefined"` 或包含换行/控制符时,请求构造的 `Authorization` 等头部抛出 `Invalid character in header content` 导致前端所有接口报错的问题(issue #1000)。新增 `safeHeaderValue` / `safeBearerHeader` 工具,对全部认证头做 RFC 7230 校验,不安全的值直接跳过该头部,让 worker 走标准 401 而不是请求级崩溃
|
||||||
|
- fix: |Frontend| 修复多语言菜单在移动端顶部显示语言与版本按钮导致 Header 横向拥挤或溢出的问题,移动端仅保留菜单按钮并将语言/版本入口放入抽屉
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- refactor: |Worker| 拆分 `mails_api/index.ts` 与 `admin_api/index.ts`,入口只负责挂路由,业务拆到各自的 `*_api.ts` 文件(`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`),保持路径与行为不变
|
||||||
|
|
||||||
|
## v1.7.0
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
@@ -32,7 +159,7 @@
|
|||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
## v1.6.0(main)
|
## v1.6.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -56,7 +183,7 @@
|
|||||||
- docs: |发送邮件| 增强 SMTP_CONFIG 字段说明和多域名示例,新增发信余额机制说明
|
- docs: |发送邮件| 增强 SMTP_CONFIG 字段说明和多域名示例,新增发信余额机制说明
|
||||||
- docs: |Email Routing| 补充子域名需单独启用 Email Routing 的说明,避免仅在一级域名开启导致子域收不到邮件(#969)
|
- docs: |Email Routing| 补充子域名需单独启用 Email Routing 的说明,避免仅在一级域名开启导致子域收不到邮件(#969)
|
||||||
|
|
||||||
## v1.5.0(main)
|
## v1.5.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -116,7 +243,7 @@
|
|||||||
|
|
||||||
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
|
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
|
||||||
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
|
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
|
||||||
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
|
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
|||||||
+132
-5
@@ -6,15 +6,142 @@
|
|||||||
<a href="CHANGELOG_EN.md">English</a>
|
<a href="CHANGELOG_EN.md">English</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## v1.8.0(main)
|
## v1.12.0(main)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
|
||||||
|
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
|
||||||
|
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
|
||||||
|
- feat: |Mail| Add optional read/unread status with click-to-read and manual status switching
|
||||||
|
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||||
|
- feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
|
||||||
|
- feat: |Admin API| Add `ADMIN_API_IP_WHITELIST` to restrict all admin endpoints by source IP
|
||||||
|
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish site authentication errors to prevent login loops; move E2E test endpoints out of production code
|
||||||
|
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
|
||||||
|
- fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading
|
||||||
|
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
||||||
|
- fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder
|
||||||
|
- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
## v1.7.0(main)
|
- fix: |Frontend| Unify core mailbox, email-address, user-account, and Admin Console terminology, and fill missing additional-locale strings for mail status and remote images (issue #1129)
|
||||||
|
- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
||||||
|
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
||||||
|
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
||||||
|
|
||||||
|
## v1.11.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |Frontend| Add Normal, Random, and Custom subdomain mode selection within the random-subdomain scope (issue #1108)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Frontend| Relabel mailbox settings and deletion actions as email-address operations, and clarify that send permission and balance are managed independently for the current email address
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- docs: |Send Mail| Document the differences between user accounts, email addresses, and send permission, including how to request permission for the currently selected address
|
||||||
|
|
||||||
|
- fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103)
|
||||||
|
|
||||||
|
- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103)
|
||||||
|
|
||||||
|
- feat: |Worker| Process mail, sent-mail, and creation/activity-based address cleanup in batches of 3000 by default, configurable through `CLEANUP_BATCH_SIZE` up to 5000, reducing per-run scans and deletes (issue #1103)
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps
|
||||||
|
- fix: |E2E| Cover cleanup batch limits, continuation on later runs, preservation of recent data, and address-related data cleanup
|
||||||
|
|
||||||
|
## v1.10.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |Admin| Add `GET /admin/mails/:id` for administrators to fetch a single mail by ID across mailboxes, including gzip-compressed storage support (issue #1096)
|
||||||
|
- feat: |Frontend| Add a "Full-width mailbox list view" toggle in Appearance settings. When enabled, the mailbox shows a full-width list of subjects and body previews by default; clicking a mail expands it into the two-pane split view, clicking the same mail again returns to the list view; in multi-select mode, clicking a mail updates both its checked state and the right-side preview while disabling same-mail collapse, and the split width still follows the "Left list width in two-column mailbox view" setting. Defaults to off, preserving the original two-pane behavior
|
||||||
|
- feat: |Frontend| Add "Body Preview Lines" in Appearance settings for the full-width mailbox list view, allowing runtime control over the body-preview clamp. It defaults to 2 lines, and 0 disables previews
|
||||||
|
- feat: |Frontend| Add an "Automatically load external images in mail body" toggle in Appearance settings. When disabled, the mail body (including fullscreen view) is run through DOMPurify and an allowlist policy: only references that can be *proven* local are kept (`cid:`, `data:image/`, `blob:` and same-origin relative paths), everything else is blocked. Elements that fetch on their own or change how relative URLs resolve — `base`, `meta`, `script`, `link`, `iframe`, `object`, `embed`, `noscript` — are removed in this mode, while `<style>` is kept with remote `url()`, `image-set()` and `@import` references substituted. A banner above the body reports how many resources were blocked and loads them for that mail on demand; defaults to on, preserving the previous behavior (issue #1073)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Frontend| Preserve external navigation links on `<a>` and `<area>` elements when automatic remote-image loading is disabled, and block remote CSS resources hidden behind escaped function or at-rule names
|
||||||
|
- fix: |Frontend| Sanitize HTML announcements in both the About page and startup notification through a shared DOMPurify helper, preventing executable tags or event attributes in `ANNOUNCEMENT` from causing XSS
|
||||||
|
- fix: |Worker| Align junk-mail checking with authentication standards: treat SPF, DKIM, and DMARC `none` plus SPF/DKIM `neutral` as absent, and ignore unregistered results and unsupported method versions; `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
|
||||||
|
- fix: |Admin| When deleting an address from the admin panel, delete its mails, sender records, sendbox and auto-reply entries before removing the address row itself; previously the address row was deleted first, so the name-based subqueries matched nothing and the mails were left orphaned in the database
|
||||||
|
- fix: |AI Extract| Strengthen the prompt to keep original link domains from the email, preventing small models from rewriting verification-link domains (issue #1072)
|
||||||
|
- fix: |AI Extract| Convert HTML-only mail bodies into compact readable text before sending them to Workers AI, preventing long templates from pushing verification codes past the 4000-character truncation window
|
||||||
|
- fix: |Frontend| Add mobile Header page padding so the title and menu button no longer sit too close to the screen edge
|
||||||
|
- fix: |IMAP Proxy| Fix IMAP `STORE` not actually marking mail as read: messages are no longer hardcoded to `\Seen`, and `SimpleMailbox` flag changes are now persisted to a local SQLite file (new `imap_flag_db_path` setting) so the read/unread state survives a client disconnect and reconnect (e.g. Thunderbird polling) instead of resetting on every new connection (issue #1074)
|
||||||
|
- fix: |IMAP Proxy| Fix `SEARCH UNSEEN` returning every message: `SimpleMailbox.search()` now evaluates `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` and their negations against the persisted flags, combining multiple keys with AND; search keys it cannot evaluate keep the previous behaviour of matching everything
|
||||||
|
- fix: |IMAP Proxy| Fix fetches never marking mail as read: `BODY[...]`, `RFC822` and `RFC822.TEXT` fetches now set `\Seen` per RFC 3501, while `BODY.PEEK[...]`, `RFC822.HEADER` and metadata-only fetches (e.g. `FLAGS`) do not
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- test: |Worker| Add junk_mail_policy regression tests (issue #1084): `none`/`neutral` results for SPF/DKIM/DMARC are treated as the method being absent, explicit `fail` results are still rejected, and `JUNK_MAIL_FORCE_PASS_LIST` only accepts an explicit `pass`
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- docs: |README| Add a complete Japanese README and Japanese navigation links to the Chinese and English READMEs
|
||||||
|
- feat: |Frontend| Lower the "Left list width in two-column mailbox view" minimum from 0.25 to 0 so the left list pane can fully collapse for a near-fullscreen content view, with a 0 mark added; applies to both the inbox and send-box two-pane splits, and clarifies the Appearance setting label so it is clear the value controls the left mail list width
|
||||||
|
|
||||||
|
## v1.9.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |AI Extract| Fall back to a built-in regex verification-code extractor (English / Chinese / Japanese / Korean, with year and `YYYYMMDD` date rejection) when no Workers AI binding is configured, so self-hosted deployments without Workers AI still surface codes in Telegram pushes and webhooks
|
||||||
|
- feat: |Telegram| Show AI extraction results in Telegram new-mail notifications and `/mails` history views, including verification codes, auth links, service links, and subscription links
|
||||||
|
- feat: |Webhook| Support AI extraction placeholders in mail webhook templates, including `aiExtractType`, `aiExtractResult`, and `aiExtractResultText`
|
||||||
|
- feat: |Frontend| Add `DISABLE_SHOW_GITHUB_FOR_USER` to hide the Header GitHub/version entry from normal users while keeping it visible to admin users (issue #1041)
|
||||||
|
- feat: |Frontend| Upgrade the address credential dialog to "Address Credentials & Connection Methods" and reuse it for both normal users and admin-created addresses; support showing AI Agent access via `ENABLE_AGENT_EMAIL_INFO` and SMTP/IMAP client settings via `SMTP_IMAP_PROXY_CONFIG`
|
||||||
|
- docs: |Random Subdomain| Clarify in the "Use Random Subdomain" frontend tip and the `subdomain` / `worker-vars` docs (zh & en) that receiving mail on `name@<random>.abc.com` requires a wildcard `*` MX record under the base domain in DNS, because Cloudflare Email Routing does not inherit the apex configuration onto subdomains (issue #1035)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Admin| Hash address passwords in the frontend before admin reset requests, and make the backend accept and store only the hash instead of plaintext
|
||||||
|
- fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields
|
||||||
|
- fix: |Address| Normalize whitespace and casing for configured domains, inbound recipient domains, and prefixes across `DOMAINS`, `DEFAULT_DOMAINS`, `USER_ROLES.domains`, random subdomains, forwarding rules, SMTP, and `SEND_MAIL` domain matching, preserve blank-domain catch-all forwarding rules, and clarify that empty `DEFAULT_DOMAINS` / role domains fall back to `DOMAINS`, to avoid create, receive, forward, or send failures caused by mixed-case configuration or inbound recipient domains (issue #926)
|
||||||
|
- fix: |AI Extract| Switch the default Workers AI model for AI email recognition to the JSON Mode-compatible, non-deprecated `@cf/meta/llama-3.1-8b-instruct-fast`, and document structured-output compatibility guidance for `@cf/zai-org/glm-4.7-flash` (issue #1029)
|
||||||
|
- fix: |CI| Upgrade GitHub Actions and e2e Docker images to Node.js 24 to satisfy Wrangler 4.90.0 runtime requirements
|
||||||
|
- fix: |Frontend| Prevent iOS Safari from auto-zooming the page when focusing mobile form controls with small font sizes
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
## v1.8.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- feat: |Frontend| Add six-language frontend support (`zh` / `en` / `es` / `pt-BR` / `ja` / `de`), keep `zh` as the default locale; locale-unprefixed routes (for example `/` and `/user`) render in Chinese by default while still recording browser language as the stored preference. Explicit locale switches are persisted, and the current route, query string, and canonical locale URL stay in sync during switching
|
||||||
|
- feat: |API| Add server-side parsed-mail endpoints `/api/parsed_mails` and `/api/parsed_mail/:id` that return `sender` / `subject` / `text` / `html` / `attachments` metadata directly (reuses `commonParseMail`), so AI agents no longer need a client-side MIME parser
|
||||||
|
- feat: |Skill| Bundle a read-only skill `cf-temp-mail-agent-mail` (`skills/cf-temp-mail-agent-mail/`) so AI agents like OpenClaw / Codex / Cursor can consume a mailbox with a user-supplied Address JWT + API base URL — list mails, poll verification codes, etc. — sidestepping the Turnstile challenge required to create a mailbox. Install via `npx degit dreamhunter2333/cloudflare_temp_email/skills/cf-temp-mail-agent-mail`
|
||||||
|
- docs: |Docs| Add "AI Agent Mailbox Usage" doc (`guide/feature/agent-email`) covering the `parsed_mail` API and a local-parse fallback using `mail-parser-wasm` + `postal-mime` (mirrors the frontend) when parsed endpoints are unavailable
|
||||||
|
- docs: |Docs| Make "a domain is a hard prerequisite" explicit at the top of `quick-start`, `worker-vars`, and `email-routing` (zh + en), spelling out that Cloudflare Email Routing must be enabled with email DNS records provisioned before deployment, the Catch-all rule must be bound after the Worker is deployed, and subdomains do not inherit the parent domain's Email Routing — so users no longer start deploying without a usable domain and end up unable to receive mail (issue #1004)
|
||||||
|
- docs: |Deployment troubleshooting| Improve docs for recent UI-deployment and upgrade issues: document `nodejs_compat`, the required uppercase `DB` D1 binding, `/open_api/settings` verification, backend API URL entry, Cloudflare security challenges causing `Network Error`, D1 size limits and Cron Trigger cleanup, GitHub OAuth public email requirements, the difference between admin passwords and user accounts, and the `enableRandomSubdomain` API flag; move the Help/FAQ menu directly after Core Configuration so it is easier to find
|
||||||
|
- docs: |Docs| Document how to handle the "address already exists" case when recreating an old mailbox, and clarify the GitHub Actions workflow for automatic updates with Page Functions forwarding backend requests (issues #947 #654)
|
||||||
|
- docs: |OAuth2| Document GitHub private-email login configuration using `https://api.github.com/user/emails`, a JSONPath email field, and the `user:email` scope to read the primary email (issue #655)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Frontend| Narrow address-management modal widths and keep address tables horizontally scrollable inside the modal to prevent multi-address lists from stretching the dialog
|
||||||
|
- fix: |Frontend| Fix the frontend settings bootstrap throwing an `undefined` error when `/open_api/settings` does not return a `domains` array by normalizing the field to an empty array before mapping it
|
||||||
|
- fix: |Frontend| Fix every API call crashing client-side with `Invalid character in header content ["Authorization"]` when stale localStorage credentials (`jwt` / `auth` / `adminAuth` / `userJwt` / `access_token`) are empty, the literal string `"undefined"`, or contain a stray newline or other control character (issue #1000). Adds `safeHeaderValue` / `safeBearerHeader` helpers that validate every auth header against RFC 7230 and omit the header entirely when unsafe, so the worker returns a clean 401 instead of the request being rejected by axios/undici
|
||||||
|
- fix: |Frontend| Fix the multilingual header on mobile by keeping only the menu button in the top bar and moving language/version actions into the drawer to avoid horizontal crowding or overflow
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- refactor: |Worker| Split `mails_api/index.ts` and `admin_api/index.ts` so the index files only wire routes. Business logic moved into dedicated `*_api.ts` files (`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`). Paths and behavior unchanged
|
||||||
|
|
||||||
|
## v1.7.0
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
@@ -32,7 +159,7 @@
|
|||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
## v1.6.0(main)
|
## v1.6.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -56,7 +183,7 @@
|
|||||||
- docs: |Email Sending| Enhance SMTP_CONFIG field reference and multi-domain examples, add send balance mechanism documentation
|
- 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)
|
- 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)
|
## v1.5.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
@@ -116,7 +243,7 @@
|
|||||||
|
|
||||||
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
|
- 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: |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
|
- test: |Worker| Add `/admin/test/seed_mail` test endpoint
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">中文文档</a> |
|
<a href="README.md">中文文档</a> |
|
||||||
<a href="README_EN.md">English Document</a>
|
<a href="README_EN.md">English Document</a> |
|
||||||
|
<a href="README_JA.md">日本語ドキュメント</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||||
@@ -40,7 +41,9 @@
|
|||||||
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
|
||||||
- **高性能** - Rust WASM 邮件解析,响应速度极快
|
- **高性能** - Rust WASM 邮件解析,响应速度极快
|
||||||
- **现代化界面** - 响应式设计,支持多语言,操作简便
|
- **现代化界面** - 响应式设计,支持多语言,操作简便
|
||||||
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
|
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性
|
||||||
|
- **Agent 友好** - 内置邮箱 [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md),方便 AI agent 使用邮箱
|
||||||
|
- **移动端管理** - 社区客户端 [CloudMail](https://github.com/Lur1N77777/CloudMail),支持 Android 管理后台和邮箱管理
|
||||||
|
|
||||||
## 部署文档 - 快速开始
|
## 部署文档 - 快速开始
|
||||||
|
|
||||||
@@ -72,9 +75,9 @@
|
|||||||
<summary>Star History(点击收缩/展开)</summary>
|
<summary>Star History(点击收缩/展开)</summary>
|
||||||
|
|
||||||
<picture>
|
<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: dark)" srcset="https://star-history.dera.page/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" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/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" />
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -150,6 +153,8 @@
|
|||||||
- [x] Webhook 支持,消息推送集成
|
- [x] Webhook 支持,消息推送集成
|
||||||
- [x] 支持 `CF Turnstile` 人机验证
|
- [x] 支持 `CF Turnstile` 人机验证
|
||||||
- [x] 限流配置,防止滥用
|
- [x] 限流配置,防止滥用
|
||||||
|
- [x] **Agent 友好**:内置 [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill,AI agent 可直接消费邮箱,详见 [文档](vitepress-docs/docs/zh/guide/feature/agent-email.md)
|
||||||
|
- [x] 社区移动端管理客户端:[CloudMail](https://github.com/Lur1N77777/CloudMail) 基于 Expo / React Native,面向本项目兼容 API,提供 Android 管理员后台、地址管理、收件/发件/未知邮件、验证码快捷复制、OLED 黑主题和本地分组。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -30,7 +30,8 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="README.md">中文文档</a> |
|
<a href="README.md">中文文档</a> |
|
||||||
<a href="README_EN.md">English Document</a>
|
<a href="README_EN.md">English Document</a> |
|
||||||
|
<a href="README_JA.md">日本語ドキュメント</a>
|
||||||
</p>
|
</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.
|
> 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.
|
||||||
@@ -40,7 +41,9 @@
|
|||||||
- **Completely Free** - Built on Cloudflare's free services with zero cost
|
- **Completely Free** - Built on Cloudflare's free services with zero cost
|
||||||
- **High Performance** - Rust WASM email parsing for extremely fast response
|
- **High Performance** - Rust WASM email parsing for extremely fast response
|
||||||
- **Modern UI** - Responsive design with multi-language support and easy operation
|
- **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`)
|
- **Address Password** - Support setting individual passwords for email addresses to enhance security
|
||||||
|
- **Agent-friendly** - Built-in mailbox [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) for AI agents
|
||||||
|
- **Mobile admin** - Community client [CloudMail](https://github.com/Lur1N77777/CloudMail) for Android admin and mailbox management
|
||||||
|
|
||||||
## Deployment Documentation - Quick Start
|
## Deployment Documentation - Quick Start
|
||||||
|
|
||||||
@@ -72,9 +75,9 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
|||||||
<summary>Star History (Click to expand/collapse)</summary>
|
<summary>Star History (Click to expand/collapse)</summary>
|
||||||
|
|
||||||
<picture>
|
<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: dark)" srcset="https://star-history.dera.page/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" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/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" />
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -150,6 +153,8 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
|||||||
- [x] Webhook support and message push integration
|
- [x] Webhook support and message push integration
|
||||||
- [x] Support `CF Turnstile` CAPTCHA verification
|
- [x] Support `CF Turnstile` CAPTCHA verification
|
||||||
- [x] Rate limiting configuration to prevent abuse
|
- [x] Rate limiting configuration to prevent abuse
|
||||||
|
- [x] **Agent-friendly**: bundled [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill lets AI agents consume a mailbox directly, see [docs](vitepress-docs/docs/en/guide/feature/agent-email.md)
|
||||||
|
- [x] Community mobile admin client: [CloudMail](https://github.com/Lur1N77777/CloudMail) is built with Expo / React Native for this project's compatible API, providing an Android admin console, address management, inbox/sent/unknown mail, quick verification-code copy, OLED black theme, and local grouping.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||||
|
# Cloudflare 一時メール - 無料で構築できる一時メールサービス
|
||||||
|
|
||||||
|
<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> |
|
||||||
|
<a href="README_JA.md">日本語ドキュメント</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
> 本プロジェクトは学習および個人利用のみを目的としています。違法行為には使用しないでください。使用した場合、その結果については利用者自身が責任を負うものとします。
|
||||||
|
|
||||||
|
**機能の充実した一時メールサービスです!**
|
||||||
|
|
||||||
|
- **完全無料** - Cloudflare の無料サービス上に構築され、運用コストはかかりません
|
||||||
|
- **高性能** - Rust WASM によるメール解析で極めて高速に応答します
|
||||||
|
- **モダンな UI** - 多言語対応のレスポンシブデザインで、簡単に操作できます
|
||||||
|
- **アドレスパスワード** - メールアドレスごとに個別のパスワードを設定し、セキュリティを強化できます
|
||||||
|
- **Agent フレンドリー** - AI agent がメールボックスを利用できる組み込みの [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) を提供します
|
||||||
|
- **モバイル管理** - Android の管理画面とメールボックス管理に対応したコミュニティクライアント [CloudMail](https://github.com/Lur1N77777/CloudMail) を利用できます
|
||||||
|
|
||||||
|
## デプロイドキュメント - クイックスタート
|
||||||
|
|
||||||
|
[ドキュメント](https://temp-mail-docs.awsl.uk) | [GitHub Actions デプロイガイド](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
|
||||||
|
|
||||||
|
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
|
||||||
|
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## 変更履歴
|
||||||
|
|
||||||
|
最新の更新内容については [CHANGELOG](CHANGELOG.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://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||||
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>目次(クリックして展開/折りたたみ)</summary>
|
||||||
|
|
||||||
|
- [Cloudflare 一時メール - 無料で構築できる一時メールサービス](#cloudflare-一時メール---無料で構築できる一時メールサービス)
|
||||||
|
- [デプロイドキュメント - クイックスタート](#デプロイドキュメント---クイックスタート)
|
||||||
|
- [変更履歴](#変更履歴)
|
||||||
|
- [ライブデモ](#ライブデモ)
|
||||||
|
- [主な機能](#主な機能)
|
||||||
|
- [メール処理](#メール処理)
|
||||||
|
- [ユーザー管理](#ユーザー管理)
|
||||||
|
- [管理機能](#管理機能)
|
||||||
|
- [多言語対応とインターフェース](#多言語対応とインターフェース)
|
||||||
|
- [連携と拡張](#連携と拡張)
|
||||||
|
- [技術アーキテクチャ](#技術アーキテクチャ)
|
||||||
|
- [システム構成](#システム構成)
|
||||||
|
- [技術スタック](#技術スタック)
|
||||||
|
- [主要コンポーネント](#主要コンポーネント)
|
||||||
|
- [コミュニティに参加](#コミュニティに参加)
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 主な機能
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>主な機能の詳細(クリックして展開/折りたたみ)</summary>
|
||||||
|
|
||||||
|
### メール処理
|
||||||
|
|
||||||
|
- [x] `rust wasm` でメールを高速に解析します。ほぼすべてのメールを解析でき、Node.js の解析モジュールで失敗するメールも rust wasm なら正常に解析できます
|
||||||
|
- [x] **AI メール認識** - Cloudflare Workers AI を使用して、メール内の確認コード、認証リンク、サービスリンクなどの重要情報を自動抽出します
|
||||||
|
- [x] 指定したベースドメインに対して、ランダムな第 2 レベルサブドメインのメールボックスを任意で作成できます
|
||||||
|
- [x] `DKIM` 検証付きのメール送信に対応します
|
||||||
|
- [x] `SMTP` や `Resend` など、複数の送信方法に対応します
|
||||||
|
- [x] 添付ファイルの表示機能を備え、添付画像も表示できます
|
||||||
|
- [x] S3 での添付ファイルの保存と削除に対応します
|
||||||
|
- [x] スパム検出とブラックリスト/ホワイトリストの設定に対応します
|
||||||
|
- [x] グローバル転送先アドレスを指定できるメール転送機能を備えています
|
||||||
|
|
||||||
|
### ユーザー管理
|
||||||
|
|
||||||
|
- [x] `credentials` を使用して、以前利用したメールボックスへ再ログインできます
|
||||||
|
- [x] 完全なユーザー登録・ログイン機能を備えています。メールアドレスを紐付けると、そのアドレスのメール JWT 資格情報を自動取得し、複数のメールボックスを切り替えられます
|
||||||
|
- [x] `OAuth2` によるサードパーティログイン(Github、Authentik など)に対応します
|
||||||
|
- [x] `Passkey` によるパスワードレスログインに対応します
|
||||||
|
- [x] 複数ロールのドメインおよびプレフィックスを設定できるユーザーロール管理に対応します
|
||||||
|
- [x] アドレスやキーワードで絞り込めるユーザー受信箱を提供します
|
||||||
|
|
||||||
|
### 管理機能
|
||||||
|
|
||||||
|
- [x] 完全な admin コンソールを備えています
|
||||||
|
- [x] `admin` バックエンドからプレフィックスのないメールボックスを作成できます
|
||||||
|
- [x] admin ユーザー管理画面でユーザーのアドレスを確認できます
|
||||||
|
- [x] 複数のクリーンアップ戦略を選べる定期クリーンアップ機能を備えています
|
||||||
|
- [x] カスタム名のメールボックスを取得でき、`admin` でブラックリストを設定できます
|
||||||
|
- [x] アクセスパスワードを追加し、プライベートサイトとして利用できます
|
||||||
|
|
||||||
|
### 多言語対応とインターフェース
|
||||||
|
|
||||||
|
- [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` CAPTCHA 検証に対応します
|
||||||
|
- [x] 悪用を防ぐためのレート制限を設定できます
|
||||||
|
- [x] **Agent フレンドリー**:組み込みの [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill により、AI agent がメールボックスを直接利用できます。詳しくは[ドキュメント](vitepress-docs/docs/en/guide/feature/agent-email.md)を参照してください
|
||||||
|
- [x] コミュニティのモバイル管理クライアント:[CloudMail](https://github.com/Lur1N77777/CloudMail) は本プロジェクト互換の API 向けに Expo / React Native で構築されており、Android 管理コンソール、アドレス管理、受信済み/送信済み/不明メール、確認コードのクイックコピー、OLED ブラックテーマ、ローカルグループ分けを提供します
|
||||||
|
|
||||||
|
</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 でドメインレコードを追加する際、DNS プロバイダーが第 3 レベルドメイン a.b.com をホストしている場合は、Resend が生成したデフォルト名から第 2 レベルドメインのプレフィックス b を削除してください。削除しないと a.b.b.com が追加され、検証に失敗します。レコードを追加した後、次のコマンドで確認できます。
|
||||||
|
```bash
|
||||||
|
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## コミュニティに参加
|
||||||
|
|
||||||
|
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT UNIQUE NOT NULL,
|
||||||
|
redeem_type TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
redeemed_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
|
||||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
|||||||
raw TEXT,
|
raw TEXT,
|
||||||
raw_blob BLOB,
|
raw_blob BLOB,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
|
is_unread INTEGER,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,3 +120,19 @@ CREATE TABLE IF NOT EXISTS user_passkeys (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT UNIQUE NOT NULL,
|
||||||
|
redeem_type TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
redeemed_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Keep this version in sync with @playwright/test in package.json
|
# Keep this version in sync with @playwright/test in package.json
|
||||||
FROM mcr.microsoft.com/playwright:v1.58.2-noble
|
FROM mcr.microsoft.com/playwright:v1.63.0-noble
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-slim
|
FROM node:24-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
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
|
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||||
@@ -33,6 +33,7 @@ config.server = {\
|
|||||||
"/admin": { target: workerTarget, changeOrigin: true },\
|
"/admin": { target: workerTarget, changeOrigin: true },\
|
||||||
"/user_api": { target: workerTarget, changeOrigin: true },\
|
"/user_api": { target: workerTarget, changeOrigin: true },\
|
||||||
"/open_api": { target: workerTarget, changeOrigin: true },\
|
"/open_api": { target: workerTarget, changeOrigin: true },\
|
||||||
|
"/redeem_api": { target: workerTarget, changeOrigin: true },\
|
||||||
"/external": { target: workerTarget, changeOrigin: true },\
|
"/external": { target: workerTarget, changeOrigin: true },\
|
||||||
"/health_check": { target: workerTarget, changeOrigin: true },\
|
"/health_check": { target: workerTarget, changeOrigin: true },\
|
||||||
},\
|
},\
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-slim
|
FROM node:24-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
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
|
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||||
@@ -10,10 +10,12 @@ COPY worker/patches/ patches/
|
|||||||
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
||||||
|
|
||||||
COPY worker/src/ src/
|
COPY worker/src/ src/
|
||||||
|
COPY e2e/fixtures/*.ts /app/e2e/fixtures/
|
||||||
|
RUN ln -s /app/worker/node_modules /app/e2e/node_modules
|
||||||
COPY worker/tsconfig.json ./
|
COPY worker/tsconfig.json ./
|
||||||
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
||||||
COPY ${WRANGLER_TOML} wrangler.toml
|
COPY ${WRANGLER_TOML} wrangler.toml
|
||||||
|
|
||||||
EXPOSE 8787
|
EXPOSE 8787
|
||||||
|
|
||||||
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
|
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0", "--test-scheduled"]
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ Test results and HTML reports are exported via volumes:
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
|
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
|
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
|
||||||
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
|
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
|
||||||
- SMTP pointed at Mailpit container (`mailpit:1025`)
|
- SMTP pointed at Mailpit container (`mailpit:1025`)
|
||||||
|
|
||||||
|
Test-only endpoints under `/__test/*` are registered in `e2e/fixtures/worker.ts` and are excluded from the production Worker.
|
||||||
|
|||||||
+38
-1
@@ -42,7 +42,7 @@ services:
|
|||||||
dockerfile: e2e/Dockerfile.worker
|
dockerfile: e2e/Dockerfile.worker
|
||||||
ports:
|
ports:
|
||||||
- "8790:8790"
|
- "8790:8790"
|
||||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
|
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0", "--test-scheduled"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -90,6 +90,24 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
worker-site-password:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: e2e/Dockerfile.worker
|
||||||
|
args:
|
||||||
|
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.site-password
|
||||||
|
ports:
|
||||||
|
- "8792:8792"
|
||||||
|
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8792", "--ip", "0.0.0.0"]
|
||||||
|
depends_on:
|
||||||
|
- mailpit
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "-H", "x-custom-auth: e2e-site-pass", "http://localhost:8792/health_check"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -100,6 +118,19 @@ services:
|
|||||||
worker:
|
worker:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend-env-off:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: e2e/Dockerfile.frontend
|
||||||
|
ports:
|
||||||
|
- "5174:5174"
|
||||||
|
command: ["pnpm", "exec", "vite", "--port", "5174", "--host", "0.0.0.0"]
|
||||||
|
environment:
|
||||||
|
VITE_WORKER_URL: http://worker-env-off:8790
|
||||||
|
depends_on:
|
||||||
|
worker-env-off:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
smtp-proxy:
|
smtp-proxy:
|
||||||
build:
|
build:
|
||||||
context: ../smtp_proxy_server
|
context: ../smtp_proxy_server
|
||||||
@@ -147,7 +178,9 @@ services:
|
|||||||
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
||||||
WORKER_GZIP_URL: http://worker-gzip:8788
|
WORKER_GZIP_URL: http://worker-gzip:8788
|
||||||
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
|
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
|
||||||
|
WORKER_URL_SITE_PASSWORD: http://worker-site-password:8792
|
||||||
FRONTEND_URL: https://frontend:5173
|
FRONTEND_URL: https://frontend:5173
|
||||||
|
FRONTEND_URL_ENV_OFF: https://frontend-env-off:5174
|
||||||
MAILPIT_API: http://mailpit:8025/api
|
MAILPIT_API: http://mailpit:8025/api
|
||||||
SMTP_PROXY_HOST: smtp-proxy
|
SMTP_PROXY_HOST: smtp-proxy
|
||||||
SMTP_PROXY_SMTP_PORT: "8025"
|
SMTP_PROXY_SMTP_PORT: "8025"
|
||||||
@@ -167,8 +200,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
worker-send-mail-domain:
|
worker-send-mail-domain:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
worker-site-password:
|
||||||
|
condition: service_healthy
|
||||||
frontend:
|
frontend:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
frontend-env-off:
|
||||||
|
condition: service_started
|
||||||
smtp-proxy:
|
smtp-proxy:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
smtp-proxy-tls:
|
smtp-proxy-tls:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class EmailMessage {
|
||||||
|
constructor(public from: string, public to: string, public raw: string) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Direct DB insert — bypasses the email() handler.
|
||||||
|
const seedMail = async (request: Request, env: Bindings) => {
|
||||||
|
const { address, source, raw, message_id, created_at, address_updated_at, address_created_at } = await request.json<{
|
||||||
|
address: string; source?: string; raw: string; message_id?: string;
|
||||||
|
created_at?: string; address_updated_at?: string; address_created_at?: string;
|
||||||
|
}>();
|
||||||
|
if (!address || !raw) {
|
||||||
|
return new Response("address and raw are required", { status: 400 });
|
||||||
|
}
|
||||||
|
if (raw.length > 1_000_000) {
|
||||||
|
return new Response("raw content too large", { status: 400 });
|
||||||
|
}
|
||||||
|
if (message_id && message_id.length > 255) {
|
||||||
|
return new Response("message_id too long", { status: 400 });
|
||||||
|
}
|
||||||
|
if (address_updated_at !== undefined) {
|
||||||
|
await env.DB.prepare(`UPDATE address SET updated_at = ? WHERE name = ?`)
|
||||||
|
.bind(address_updated_at, address).run();
|
||||||
|
}
|
||||||
|
if (address_created_at !== undefined) {
|
||||||
|
await env.DB.prepare(`UPDATE address SET created_at = ? WHERE name = ?`)
|
||||||
|
.bind(address_created_at, address).run();
|
||||||
|
}
|
||||||
|
const msgId = message_id || `<e2e-${Date.now()}@test>`;
|
||||||
|
const { success } = await env.DB.prepare(
|
||||||
|
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
|
||||||
|
+ ` VALUES (?, ?, ?, ?, COALESCE(?, datetime('now')))`
|
||||||
|
).bind(msgId, source || address, address, raw, created_at ?? null).run();
|
||||||
|
return Response.json({ success });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exercises the real email() handler with a mock ForwardableEmailMessage.
|
||||||
|
const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => {
|
||||||
|
const { from, to, raw, ai_extract_result } = await request.json<{
|
||||||
|
from: string; to: string; raw: string; ai_extract_result?: unknown;
|
||||||
|
}>();
|
||||||
|
if (!from || !to || !raw) {
|
||||||
|
return new Response("from, to and raw are required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
|
||||||
|
const headerSection = raw.substring(0, Math.max(0, raw.indexOf('\r\n\r\n')));
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const line of headerSection.replace(/\r\n(?=[ \t])/g, ' ').split('\r\n')) {
|
||||||
|
const idx = line.indexOf(':');
|
||||||
|
if (idx > 0) headers.append(line.substring(0, idx).trim(), line.substring(idx + 1).trim());
|
||||||
|
}
|
||||||
|
if (!headers.has('Message-ID')) headers.set('Message-ID', `<e2e-${Date.now()}@test>`);
|
||||||
|
|
||||||
|
const rawBytes = new TextEncoder().encode(raw);
|
||||||
|
const state = { rejected: undefined as string | undefined, replyCalled: false, forwardedTo: [] as string[] };
|
||||||
|
const mockMessage: ForwardableEmailMessage = {
|
||||||
|
from, to, headers,
|
||||||
|
rawSize: rawBytes.byteLength,
|
||||||
|
raw: new ReadableStream({ start(ctrl) { ctrl.enqueue(rawBytes); ctrl.close(); } }),
|
||||||
|
setReject(reason: string) { state.rejected = reason; },
|
||||||
|
forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; },
|
||||||
|
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
|
||||||
|
};
|
||||||
|
const { email: emailHandler } = await import('../../worker/src/email');
|
||||||
|
const aiExtractEnvOverrides: Partial<Bindings> = {
|
||||||
|
ENABLE_AI_EMAIL_EXTRACT: true,
|
||||||
|
AI: {
|
||||||
|
run: async () => ({ response: ai_extract_result })
|
||||||
|
} as unknown as Ai,
|
||||||
|
};
|
||||||
|
const emailEnv = ai_extract_result
|
||||||
|
? { ...env, ...aiExtractEnvOverrides }
|
||||||
|
: env;
|
||||||
|
await emailHandler(mockMessage, emailEnv, ctx);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
success: !state.rejected,
|
||||||
|
replyCalled: state.replyCalled,
|
||||||
|
forwardedTo: state.forwardedTo,
|
||||||
|
...(state.rejected ? { rejected: state.rejected } : {})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { seedMail, receiveMail };
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { APIRequestContext } from '@playwright/test';
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
export const WORKER_URL = process.env.WORKER_URL!;
|
export const WORKER_URL = process.env.WORKER_URL!;
|
||||||
@@ -6,10 +7,19 @@ 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_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
||||||
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
||||||
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
|
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
|
||||||
|
export const WORKER_URL_SITE_PASSWORD = process.env.WORKER_URL_SITE_PASSWORD || '';
|
||||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||||
|
export const FRONTEND_URL_ENV_OFF = process.env.FRONTEND_URL_ENV_OFF || '';
|
||||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||||
export const TEST_DOMAIN = 'test.example.com';
|
export const TEST_DOMAIN = 'test.example.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SHA-256 hash matching the frontend hashPassword utility.
|
||||||
|
*/
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
return createHash('sha256').update(password).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new email address via the worker API.
|
* Create a new email address via the worker API.
|
||||||
* Appends a timestamp suffix to avoid UNIQUE constraint collisions
|
* Appends a timestamp suffix to avoid UNIQUE constraint collisions
|
||||||
@@ -19,10 +29,11 @@ export const TEST_DOMAIN = 'test.example.com';
|
|||||||
export async function createTestAddress(
|
export async function createTestAddress(
|
||||||
ctx: APIRequestContext,
|
ctx: APIRequestContext,
|
||||||
name: string,
|
name: string,
|
||||||
domain: string = TEST_DOMAIN
|
domain: string = TEST_DOMAIN,
|
||||||
|
workerUrl: string = WORKER_URL,
|
||||||
): Promise<{ jwt: string; address: string; address_id: number }> {
|
): Promise<{ jwt: string; address: string; address_id: number }> {
|
||||||
const uniqueName = `${name}${Date.now()}`;
|
const uniqueName = `${name}${Date.now()}`;
|
||||||
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
|
const res = await ctx.post(`${workerUrl}/api/new_address`, {
|
||||||
data: { name: uniqueName, domain },
|
data: { name: uniqueName, domain },
|
||||||
});
|
});
|
||||||
if (!res.ok()) {
|
if (!res.ok()) {
|
||||||
@@ -67,7 +78,7 @@ export async function seedTestMail(
|
|||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
if (!res.ok()) {
|
if (!res.ok()) {
|
||||||
@@ -156,14 +167,20 @@ export function onMailpitMessage(
|
|||||||
ws.on('open', () => readyResolve());
|
ws.on('open', () => readyResolve());
|
||||||
|
|
||||||
ws.on('message', (data: WebSocket.Data) => {
|
ws.on('message', (data: WebSocket.Data) => {
|
||||||
try {
|
// Mailpit may batch multiple newline-separated events in one message.
|
||||||
const event = JSON.parse(data.toString());
|
for (const line of data.toString().split('\n')) {
|
||||||
if (event.Type === 'new' && predicate(event.Data)) {
|
if (settled) return;
|
||||||
clearTimeout(timer);
|
try {
|
||||||
ws.close();
|
const event = JSON.parse(line);
|
||||||
if (!settled) { settled = true; resolve(event.Data); }
|
if (event.Type === 'new' && predicate(event.Data)) {
|
||||||
}
|
clearTimeout(timer);
|
||||||
} catch { /* ignore parse errors */ }
|
settled = true;
|
||||||
|
ws.close();
|
||||||
|
resolve(event.Data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed events without dropping the remaining ones */ }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import worker from '../../worker/src/worker';
|
||||||
|
import { CONSTANTS } from '../../worker/src/constants';
|
||||||
|
import mailApi from './mail-api';
|
||||||
|
|
||||||
|
const testApi = new Hono<HonoCustomType>();
|
||||||
|
testApi.post('/seed_mail', c => mailApi.seedMail(c.req.raw, c.env));
|
||||||
|
testApi.post('/receive_mail', c => mailApi.receiveMail(c.req.raw, c.env, c.executionCtx as ExecutionContext));
|
||||||
|
testApi.get('/telegram_binding', async c => {
|
||||||
|
const address = c.req.query('address');
|
||||||
|
if (!address) return c.text('address is required', 400);
|
||||||
|
return c.json(await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<HonoCustomType>();
|
||||||
|
app.route('/__test', testApi);
|
||||||
|
app.all('*', c => worker.fetch(c.req.raw, c.env, c.executionCtx));
|
||||||
|
|
||||||
|
export default { ...worker, fetch: app.fetch };
|
||||||
@@ -1,26 +1,38 @@
|
|||||||
name = "cloudflare_temp_email"
|
name = "cloudflare_temp_email"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
PREFIX = "tmp"
|
PREFIX = "TMP"
|
||||||
DEFAULT_DOMAINS = ["test.example.com"]
|
DEFAULT_DOMAINS = []
|
||||||
DOMAINS = ["test.example.com"]
|
DOMAINS = ["TEST.EXAMPLE.COM", "MANUAL.EXAMPLE.COM"]
|
||||||
|
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||||
|
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
|
||||||
|
USER_ROLES = [
|
||||||
|
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
|
||||||
|
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||||
|
]
|
||||||
JWT_SECRET = "e2e-test-secret-key"
|
JWT_SECRET = "e2e-test-secret-key"
|
||||||
|
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
|
||||||
BLACK_LIST = ""
|
BLACK_LIST = ""
|
||||||
ENABLE_USER_CREATE_EMAIL = true
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
ENABLE_USER_DELETE_EMAIL = true
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
ENABLE_MAIL_READ_STATUS = true
|
||||||
|
ENABLE_REDEEM_CODE = true
|
||||||
|
REDEEM_CODE_URL = "https://example.com/redeem-codes"
|
||||||
ENABLE_AUTO_REPLY = true
|
ENABLE_AUTO_REPLY = true
|
||||||
DEFAULT_SEND_BALANCE = 10
|
DEFAULT_SEND_BALANCE = 10
|
||||||
|
NO_LIMIT_SEND_ROLE = "case-role"
|
||||||
ENABLE_ADDRESS_PASSWORD = true
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
|
ADMIN_USER_ROLE = "admin"
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
CLEANUP_BATCH_SIZE = 10
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
[[kv_namespaces]]
|
[[kv_namespaces]]
|
||||||
@@ -31,3 +43,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email"
|
database_name = "e2e-temp-email"
|
||||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name = "cloudflare_temp_email_env_off"
|
name = "cloudflare_temp_email_env_off"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
@@ -13,13 +13,14 @@ JWT_SECRET = "e2e-test-secret-key-env-off"
|
|||||||
BLACK_LIST = ""
|
BLACK_LIST = ""
|
||||||
ENABLE_USER_CREATE_EMAIL = true
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
ENABLE_USER_DELETE_EMAIL = false
|
ENABLE_USER_DELETE_EMAIL = false
|
||||||
|
DISABLE_ADDRESS_UPDATED_AT = true
|
||||||
|
ENABLE_REDEEM_CODE = false
|
||||||
ENABLE_AUTO_REPLY = true
|
ENABLE_AUTO_REPLY = true
|
||||||
DEFAULT_SEND_BALANCE = 10
|
DEFAULT_SEND_BALANCE = 10
|
||||||
ENABLE_ADDRESS_PASSWORD = true
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
"""
|
"""
|
||||||
@@ -32,3 +33,6 @@ id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email-env-off"
|
database_name = "e2e-temp-email-env-off"
|
||||||
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name = "cloudflare_temp_email_gzip"
|
name = "cloudflare_temp_email_gzip"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
@@ -18,7 +18,6 @@ ENABLE_ADDRESS_PASSWORD = true
|
|||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
|
||||||
ENABLE_MAIL_GZIP = true
|
ENABLE_MAIL_GZIP = true
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
@@ -32,3 +31,6 @@ id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email-gzip"
|
database_name = "e2e-temp-email-gzip"
|
||||||
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name = "cloudflare_temp_email"
|
name = "cloudflare_temp_email"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
@@ -9,10 +9,10 @@ send_email = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
PREFIX = "tmp"
|
PREFIX = "TMP"
|
||||||
DEFAULT_DOMAINS = ["test.example.com"]
|
DEFAULT_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||||
DOMAINS = ["test.example.com"]
|
DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||||
SEND_MAIL_DOMAINS = ["test.example.com"]
|
SEND_MAIL_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||||
JWT_SECRET = "e2e-test-secret-key"
|
JWT_SECRET = "e2e-test-secret-key"
|
||||||
BLACK_LIST = ""
|
BLACK_LIST = ""
|
||||||
ENABLE_USER_CREATE_EMAIL = true
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
@@ -23,9 +23,8 @@ ENABLE_ADDRESS_PASSWORD = true
|
|||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
[[kv_namespaces]]
|
[[kv_namespaces]]
|
||||||
@@ -36,3 +35,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email"
|
database_name = "e2e-temp-email"
|
||||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name = "cloudflare_temp_email_site_password"
|
||||||
|
main = "../e2e/fixtures/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"]
|
||||||
|
USER_ROLES = [
|
||||||
|
{ domains = ["test.example.com"], role = "case-role", prefix = "" },
|
||||||
|
]
|
||||||
|
ADMIN_USER_ROLE = "admin"
|
||||||
|
JWT_SECRET = "e2e-site-password-secret"
|
||||||
|
PASSWORDS = '["e2e-site-pass"]'
|
||||||
|
BLACK_LIST = ""
|
||||||
|
ENABLE_REDEEM_CODE = true
|
||||||
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
|
ADDRESS_CHECK_REGEX = "^(?!.*blocked).*$"
|
||||||
|
DISABLE_ADMIN_PASSWORD_CHECK = false
|
||||||
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "KV"
|
||||||
|
id = "e2e-test-kv-site-password-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "e2e-temp-email-site-password"
|
||||||
|
database_id = "e2e-test-db-site-password-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
Generated
+83
-120
@@ -6,14 +6,14 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "cloudflare-temp-email-e2e",
|
"name": "cloudflare-temp-email-e2e",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"imapflow": "^1.3.1",
|
"imapflow": "^1.7.8",
|
||||||
"nodemailer": "^8.0.5"
|
"nodemailer": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.63.0",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/ws": "^8.18.1",
|
||||||
"@types/ws": "^8.5.0",
|
"hono": "^4.13.7",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
@@ -23,39 +23,29 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.58.2"
|
"playwright": "1.63.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.3",
|
"version": "26.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~8.3.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": {
|
"node_modules/@types/ws": {
|
||||||
@@ -69,13 +59,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@zone-eu/mailsplit": {
|
"node_modules/@zone-eu/mailsplit": {
|
||||||
"version": "5.4.8",
|
"version": "5.4.16",
|
||||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
|
||||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
|
||||||
"license": "(MIT OR EUPL-1.1+)",
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libmime": "5.3.7",
|
"libmime": "5.4.3",
|
||||||
"libqp": "2.1.1"
|
"libqp": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,33 +79,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/encoding-japanese": {
|
"node_modules/encoding-japanese": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz",
|
||||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
"integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/hono": {
|
||||||
"version": "2.3.2",
|
"version": "4.13.7",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
@@ -129,38 +114,25 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imapflow": {
|
"node_modules/imapflow": {
|
||||||
"version": "1.3.1",
|
"version": "1.7.8",
|
||||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
|
||||||
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
|
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.8",
|
"@zone-eu/mailsplit": "5.4.16",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.3.0",
|
||||||
"iconv-lite": "0.7.2",
|
"iconv-lite": "0.7.3",
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libmime": "5.3.8",
|
"libmime": "5.4.3",
|
||||||
"libqp": "2.1.1",
|
"libqp": "2.1.1",
|
||||||
"nodemailer": "8.0.5",
|
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"socks": "2.8.7"
|
"socks": "2.8.9"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -173,29 +145,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/libmime": {
|
"node_modules/libmime": {
|
||||||
"version": "5.3.7",
|
"version": "5.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
|
||||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.3.0",
|
||||||
"iconv-lite": "0.6.3",
|
"iconv-lite": "0.7.3",
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libqp": "2.1.1"
|
"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": {
|
"node_modules/libqp": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||||
@@ -203,12 +163,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.5",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
|
||||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
@@ -258,41 +218,38 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.58.2",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.58.2"
|
"playwright-core": "1.63.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.58.2",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -346,12 +303,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socks": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.7",
|
"version": "2.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.0.1",
|
"ip-address": "^10.1.1",
|
||||||
"smart-buffer": "^4.2.0"
|
"smart-buffer": "^4.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -378,28 +335,34 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/thread-stream": {
|
"node_modules/thread-stream": {
|
||||||
"version": "4.0.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"real-require": "^0.2.0"
|
"real-require": "^1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream/node_modules/real-require": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+7
-6
@@ -3,17 +3,18 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:unit": "node --experimental-strip-types --test tests/unit/*.test.mjs",
|
||||||
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
|
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
|
||||||
"test:down": "docker compose down -v"
|
"test:down": "docker compose down -v"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.58.2",
|
"@playwright/test": "1.63.0",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/ws": "^8.18.1",
|
||||||
"@types/ws": "^8.5.0",
|
"hono": "^4.13.7",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"imapflow": "^1.3.1",
|
"imapflow": "^1.7.8",
|
||||||
"nodemailer": "^8.0.5"
|
"nodemailer": "^10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
|
||||||
|
echo "==> Waiting for site-password worker at $WORKER_URL_SITE_PASSWORD ..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" "$WORKER_URL_SITE_PASSWORD/health_check" > /dev/null 2>&1; then
|
||||||
|
echo " Site-password worker ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "ERROR: Site-password worker not ready after 60s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||||
@@ -72,6 +87,21 @@ for i in $(seq 1 60); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ -n "${FRONTEND_URL_ENV_OFF:-}" ]; then
|
||||||
|
echo "==> Waiting for env-off frontend at $FRONTEND_URL_ENV_OFF ..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl --connect-timeout 5 --max-time 10 -skf "$FRONTEND_URL_ENV_OFF" > /dev/null 2>&1; then
|
||||||
|
echo " Env-off frontend ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "ERROR: Env-off frontend not ready after 60s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
|
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
|
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
|
||||||
@@ -110,5 +140,13 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
|||||||
echo " Gzip worker database initialized"
|
echo " Gzip worker database initialized"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
|
||||||
|
echo "==> Initializing site-password worker database"
|
||||||
|
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_initialize" > /dev/null
|
||||||
|
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_migration" > /dev/null
|
||||||
|
echo " Site-password worker database initialized"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Running Playwright tests"
|
echo "==> Running Playwright tests"
|
||||||
|
npm run test:unit
|
||||||
exec npx playwright test "$@"
|
exec npx playwright test "$@"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function receiveGzipMail(
|
|||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
|
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||||
@@ -74,7 +74,7 @@ async function seedPlaintextMail(
|
|||||||
opts.text || 'Hello plaintext from E2E',
|
opts.text || 'Hello plaintext from E2E',
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
|
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/seed_mail`, {
|
||||||
data: { address, source: from, raw, message_id: messageId },
|
data: { address, source: from, raw, message_id: messageId },
|
||||||
});
|
});
|
||||||
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||||
@@ -140,6 +140,34 @@ test.describe('Mail Gzip Storage', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gzip-compressed mail is readable through admin detail API', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-detail');
|
||||||
|
try {
|
||||||
|
await receiveGzipMail(request, address, {
|
||||||
|
subject: 'Gzip Admin Detail Test',
|
||||||
|
text: 'admin compressed content',
|
||||||
|
});
|
||||||
|
|
||||||
|
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}/admin/mails/${mailId}`, {
|
||||||
|
headers: { 'x-admin-auth': 'e2e-admin-pass' },
|
||||||
|
});
|
||||||
|
expect(detailRes.ok()).toBe(true);
|
||||||
|
const mail = await detailRes.json();
|
||||||
|
expect(mail.raw).toContain('Gzip Admin Detail Test');
|
||||||
|
expect(mail.raw).toContain('admin compressed content');
|
||||||
|
expect(mail.raw_blob).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await deleteGzipAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
|
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
|
||||||
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
|
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
onMailpitMessage,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
hashPassword,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const waitForNextTimestamp = () => new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
|
|
||||||
|
test.describe('Address activity throttling', () => {
|
||||||
|
test('does not rewrite recently active addresses from user settings', async ({ request }) => {
|
||||||
|
const email = `activity-throttle-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
const address = await createTestAddress(request, 'activity-throttle');
|
||||||
|
let userId: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: { enable: true, enableMailVerify: false },
|
||||||
|
});
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt: userJwt } = await loginRes.json();
|
||||||
|
const payload = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
userId = payload.user_id;
|
||||||
|
|
||||||
|
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${address.jwt}`,
|
||||||
|
'x-user-token': userJwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bindRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const beforeRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
});
|
||||||
|
expect(beforeRes.ok()).toBe(true);
|
||||||
|
const before = await beforeRes.json();
|
||||||
|
const initialUpdatedAt = before.results.find(
|
||||||
|
(row: { name: string }) => row.name === address.address,
|
||||||
|
)?.updated_at;
|
||||||
|
expect(initialUpdatedAt).toBeTruthy();
|
||||||
|
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
});
|
||||||
|
expect(userSettingsRes.ok()).toBe(true);
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
|
||||||
|
const afterRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
});
|
||||||
|
expect(afterRes.ok()).toBe(true);
|
||||||
|
const after = await afterRes.json();
|
||||||
|
const updatedAt = after.results.find(
|
||||||
|
(row: { name: string }) => row.name === address.address,
|
||||||
|
)?.updated_at;
|
||||||
|
expect(updatedAt).toBe(initialUpdatedAt);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
if (userId) {
|
||||||
|
const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
expect(deleteUserRes.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const OLD = '2020-01-01 00:00:00';
|
||||||
|
const RECENT = new Date(Date.now() - 3_600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
type Mailbox = { address: string; address_id: number; jwt: string; password: string };
|
||||||
|
type User = { id: number; email: string; jwt: string };
|
||||||
|
|
||||||
|
for (const { base, disabled } of [
|
||||||
|
{ base: WORKER_URL, disabled: false },
|
||||||
|
{ base: WORKER_URL_ENV_OFF, disabled: true },
|
||||||
|
]) {
|
||||||
|
test.describe(`Address activity disabled: ${disabled}`, () => {
|
||||||
|
let mailboxes: Mailbox[];
|
||||||
|
let users: User[];
|
||||||
|
let orphanAddresses: string[];
|
||||||
|
let originalUserSettings: Record<string, unknown>;
|
||||||
|
|
||||||
|
async function call(request: APIRequestContext, path: string,
|
||||||
|
options: Parameters<APIRequestContext['fetch']>[1] = {}, status = 200) {
|
||||||
|
const response = await request.fetch(`${base}${path}`, options);
|
||||||
|
expect(response.status(), await response.text()).toBe(status);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
async function list(request: APIRequestContext, path: string, query: Record<string, string>) {
|
||||||
|
return (await call(request, path, { params: { limit: '100', offset: '0', ...query } })).json();
|
||||||
|
}
|
||||||
|
async function addressRow(request: APIRequestContext, mailbox: Mailbox) {
|
||||||
|
return (await list(request, '/admin/address', { query: mailbox.address })).results
|
||||||
|
.find((row: { name: string }) => row.name === mailbox.address);
|
||||||
|
}
|
||||||
|
async function newMailbox(request: APIRequestContext) {
|
||||||
|
const response = await call(request, '/admin/new_address', {
|
||||||
|
method: 'POST', data: { name: `activity${Date.now()}${mailboxes.length}`, domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
const mailbox: Mailbox = await response.json();
|
||||||
|
mailboxes.push(mailbox);
|
||||||
|
return mailbox;
|
||||||
|
}
|
||||||
|
async function newUser(request: APIRequestContext) {
|
||||||
|
const email = `activity${Date.now()}${users.length}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
await call(request, '/admin/users', { method: 'POST', data: { email, password } });
|
||||||
|
const response = await call(request, '/user_api/login', { method: 'POST', data: { email, password } });
|
||||||
|
const { jwt } = await response.json();
|
||||||
|
const { user_id: id } = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
const user = { id, email, jwt };
|
||||||
|
users.push(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
const addressAuth = (mailbox: Mailbox) => ({ Authorization: `Bearer ${mailbox.jwt}` });
|
||||||
|
const userAuth = (user: User) => ({ 'x-user-token': user.jwt });
|
||||||
|
async function bind(request: APIRequestContext, mailbox: Mailbox, user: User) {
|
||||||
|
await call(request, '/admin/users/bind_address', {
|
||||||
|
method: 'POST', data: { user_id: user.id, address_id: mailbox.address_id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function seed(request: APIRequestContext, mailbox: Mailbox, updatedAt: string | null = OLD, createdAt?: string) {
|
||||||
|
await call(request, '/__test/seed_mail', {
|
||||||
|
method: 'POST', data: {
|
||||||
|
address: mailbox.address, raw: `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: activity\r\n\r\nBody`,
|
||||||
|
address_updated_at: updatedAt, address_created_at: createdAt, created_at: OLD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(updatedAt);
|
||||||
|
}
|
||||||
|
async function expectActivity(request: APIRequestContext, mailbox: Mailbox, previous: string | null) {
|
||||||
|
if (disabled || previous === RECENT) {
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(previous);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await expect.poll(async () => (await addressRow(request, mailbox)).updated_at).not.toBe(previous);
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
|
||||||
|
}
|
||||||
|
async function send(request: APIRequestContext, mailbox: Mailbox, user?: User) {
|
||||||
|
const subject = `Activity ${disabled} ${Date.now()}`;
|
||||||
|
const listener = onMailpitMessage(mail => mail.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
const [, delivered] = await Promise.all([
|
||||||
|
call(request, user ? `/user_api/address/${mailbox.address_id}/send_mail` : '/api/send_mail', {
|
||||||
|
method: 'POST', headers: user ? userAuth(user) : addressAuth(mailbox),
|
||||||
|
data: { to_mail: 'recipient@test.example.com', subject, content: 'Activity test', is_html: false },
|
||||||
|
}),
|
||||||
|
listener.message,
|
||||||
|
]);
|
||||||
|
expect(delivered.From.Address).toBe(mailbox.address);
|
||||||
|
}
|
||||||
|
async function relatedCounts(request: APIRequestContext, mailbox: Mailbox, user: User) {
|
||||||
|
const address = await addressRow(request, mailbox);
|
||||||
|
const inbox = await list(request, '/admin/mails', { address: mailbox.address });
|
||||||
|
const sent = await list(request, '/admin/sendbox', { address: mailbox.address });
|
||||||
|
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
|
||||||
|
let replyMailbox = mailbox;
|
||||||
|
if (!address) {
|
||||||
|
await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) }, 401);
|
||||||
|
// A new credential lets us check that no old auto-reply data survived cleanup.
|
||||||
|
const [name, domain] = mailbox.address.split('@');
|
||||||
|
replyMailbox = await (await call(request, '/admin/new_address', {
|
||||||
|
method: 'POST', data: { name, domain, enablePrefix: false },
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(replyMailbox) })).json();
|
||||||
|
if (!address) await call(request, `/admin/delete_address/${replyMailbox.address_id}`, { method: 'DELETE' });
|
||||||
|
const bound = await (await call(request, '/user_api/bind_address', { headers: userAuth(user) })).json();
|
||||||
|
return [Number(!!address), inbox.count, sent.count, sender.count, Number(!!reply.subject),
|
||||||
|
bound.results.filter((row: { name: string }) => row.name === mailbox.address).length];
|
||||||
|
}
|
||||||
|
async function prepareRelated(request: APIRequestContext) {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const user = await newUser(request);
|
||||||
|
await bind(request, mailbox, user);
|
||||||
|
await call(request, '/api/auto_reply', {
|
||||||
|
method: 'POST', headers: addressAuth(mailbox),
|
||||||
|
data: { auto_reply: { subject: 'Reply', message: 'Body', enabled: false } },
|
||||||
|
});
|
||||||
|
await send(request, mailbox);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
expect(await relatedCounts(request, mailbox, user)).toEqual([1, 1, 1, 1, 1, 1]);
|
||||||
|
return { mailbox, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
expect(base).toBeTruthy();
|
||||||
|
originalUserSettings = await (await call(request, '/admin/user_settings')).json();
|
||||||
|
await call(request, '/admin/user_settings', {
|
||||||
|
method: 'POST', data: { ...originalUserSettings, enable: true, enableMailVerify: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await call(request, '/admin/user_settings', { method: 'POST', data: originalUserSettings });
|
||||||
|
});
|
||||||
|
test.beforeEach(() => {
|
||||||
|
expect(base).toBeTruthy();
|
||||||
|
mailboxes = [];
|
||||||
|
users = [];
|
||||||
|
orphanAddresses = [];
|
||||||
|
});
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
for (const mailbox of mailboxes) {
|
||||||
|
const row = await addressRow(request, mailbox);
|
||||||
|
if (row) await call(request, `/admin/delete_address/${row.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
for (const user of users) await call(request, `/admin/users/${user.id}`, { method: 'DELETE' });
|
||||||
|
for (const address of orphanAddresses) {
|
||||||
|
const mails = await list(request, '/admin/mails', { address });
|
||||||
|
for (const mail of mails.results) await call(request, `/admin/mails/${mail.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated user settings reads respect activity tracking and ownership', async ({ request }) => {
|
||||||
|
const user = await newUser(request);
|
||||||
|
const otherUser = await newUser(request);
|
||||||
|
for (const [index, previous] of [OLD, null, RECENT, OLD, OLD].entries()) {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
if (index < 3) await bind(request, mailbox, user);
|
||||||
|
if (index === 4) await bind(request, mailbox, otherUser);
|
||||||
|
await seed(request, mailbox, previous);
|
||||||
|
}
|
||||||
|
await call(request, '/user_api/settings', { headers: userAuth(user) });
|
||||||
|
await expectActivity(request, mailboxes[0], OLD);
|
||||||
|
const after = await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)));
|
||||||
|
expect(after.map(row => row.updated_at).slice(2)).toEqual([RECENT, OLD, OLD]);
|
||||||
|
if (disabled) expect(after[1].updated_at).toBeNull();
|
||||||
|
else expect(after[1].updated_at).toBeTruthy();
|
||||||
|
await call(request, '/user_api/settings', { headers: userAuth(user) });
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
expect(await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)))).toEqual(after);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of ['/api/settings', '/api/mails?limit=20&offset=0', '/api/parsed_mails?limit=20&offset=0']) {
|
||||||
|
test(`${path} respects the switch for stale, null and recent activity`, async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const other = await newMailbox(request);
|
||||||
|
await seed(request, other);
|
||||||
|
for (const previous of [OLD, null, RECENT]) {
|
||||||
|
await seed(request, mailbox, previous);
|
||||||
|
await call(request, path, { headers: addressAuth(mailbox) });
|
||||||
|
await expectActivity(request, mailbox, previous);
|
||||||
|
expect((await addressRow(request, other)).updated_at).toBe(OLD);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('later inbox pages do not refresh activity', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
for (const path of ['/api/mails?limit=20&offset=20', '/api/parsed_mails?limit=20&offset=20']) {
|
||||||
|
await call(request, path, { headers: addressAuth(mailbox) });
|
||||||
|
}
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('incoming mail is stored and both send APIs respect activity tracking', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const user = await newUser(request);
|
||||||
|
await bind(request, mailbox, user);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
const raw = `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: incoming\r\n\r\nBody`;
|
||||||
|
const received = await call(request, '/__test/receive_mail', {
|
||||||
|
method: 'POST', data: { from: 'sender@test.example.com', to: mailbox.address, raw },
|
||||||
|
});
|
||||||
|
expect((await received.json()).success).toBe(true);
|
||||||
|
expect((await list(request, '/admin/mails', { address: mailbox.address })).count).toBe(2);
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
|
||||||
|
for (const sender of [undefined, user]) {
|
||||||
|
await seed(request, mailbox);
|
||||||
|
await send(request, mailbox, sender);
|
||||||
|
await expectActivity(request, mailbox, OLD);
|
||||||
|
}
|
||||||
|
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
|
||||||
|
expect(sender.results[0].balance).toBe(8);
|
||||||
|
expect((await list(request, '/admin/sendbox', { address: mailbox.address })).count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password generation, password changes and admin resets keep working', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
expect(mailbox.password).toBeTruthy();
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
|
||||||
|
await call(request, '/api/address_login', {
|
||||||
|
method: 'POST', data: { email: mailbox.address, password: hashPassword(mailbox.password) },
|
||||||
|
});
|
||||||
|
for (const path of ['/api/address_change_password', `/admin/address/${mailbox.address_id}/reset_password`]) {
|
||||||
|
await seed(request, mailbox);
|
||||||
|
const password = hashPassword(`password-${path}`);
|
||||||
|
await call(request, path, {
|
||||||
|
method: 'POST', headers: addressAuth(mailbox), data: { password, new_password: password },
|
||||||
|
});
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).not.toBe(OLD);
|
||||||
|
await call(request, '/api/address_login', { method: 'POST', data: { email: mailbox.address, password } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('address transfer still initializes its timestamp and changes ownership', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const user = await newUser(request);
|
||||||
|
const target = await newUser(request);
|
||||||
|
await bind(request, mailbox, user);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
await call(request, '/user_api/transfer_address', {
|
||||||
|
method: 'POST', headers: userAuth(user), data: { address_id: mailbox.address_id, target_user_email: target.email },
|
||||||
|
});
|
||||||
|
const transferred = await addressRow(request, mailbox);
|
||||||
|
expect(transferred.id).not.toBe(mailbox.address_id);
|
||||||
|
expect(transferred.updated_at).toBeTruthy();
|
||||||
|
expect(transferred.updated_at).not.toBe(OLD);
|
||||||
|
const bindings = await (await call(request, '/user_api/bind_address', { headers: userAuth(target) })).json();
|
||||||
|
expect(bindings.results.map((row: { name: string }) => row.name)).toContain(mailbox.address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual inactivity cleanup protects the address and all related data when disabled', async ({ request }) => {
|
||||||
|
const { mailbox, user } = await prepareRelated(request);
|
||||||
|
for (const lang of ['en', 'zh']) {
|
||||||
|
const response = await call(request, '/admin/cleanup', {
|
||||||
|
method: 'POST', headers: { 'x-lang': lang }, data: { cleanType: 'inactiveAddress', cleanDays: 1 },
|
||||||
|
}, disabled ? 500 : 200);
|
||||||
|
if (disabled) expect(await response.text()).toBe(lang === 'zh'
|
||||||
|
? '清理失败,请检查清理配置;禁用地址活跃时间更新时,无法按不活跃时间清理。'
|
||||||
|
: 'Cleanup failed. Check your cleanup settings; inactive-address cleanup is unavailable when address activity updates are disabled.');
|
||||||
|
else expect(await response.json()).toEqual({ success: true });
|
||||||
|
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduled inactivity cleanup respects the switch and continues later cleanup', async ({ request }) => {
|
||||||
|
const { mailbox, user } = await prepareRelated(request);
|
||||||
|
const created = await newMailbox(request);
|
||||||
|
await seed(request, created, RECENT, OLD);
|
||||||
|
const original = await (await call(request, '/admin/auto_cleanup')).json();
|
||||||
|
try {
|
||||||
|
await call(request, '/admin/auto_cleanup', { method: 'POST', data: {
|
||||||
|
enableInactiveAddressAutoCleanup: true, cleanInactiveAddressDays: 1,
|
||||||
|
enableAddressAutoCleanup: true, cleanAddressDays: 1,
|
||||||
|
} });
|
||||||
|
await call(request, '/__scheduled');
|
||||||
|
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
|
||||||
|
expect(await addressRow(request, created)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await call(request, '/admin/auto_cleanup', { method: 'POST', data: original || {} });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const cleanType of ['addressCreated', 'unboundAddress', 'emptyAddress', 'mails', 'mails_unknow', 'sendbox']) {
|
||||||
|
test(`${cleanType} cleanup remains available`, async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
await seed(request, mailbox, RECENT, OLD);
|
||||||
|
let queryAddress = mailbox.address;
|
||||||
|
if (cleanType === 'emptyAddress') await call(request, `/admin/clear_inbox/${mailbox.address_id}`, { method: 'DELETE' });
|
||||||
|
if (cleanType === 'mails_unknow') {
|
||||||
|
queryAddress = `unknown${Date.now()}@test.example.com`;
|
||||||
|
orphanAddresses.push(queryAddress);
|
||||||
|
await call(request, '/__test/seed_mail', {
|
||||||
|
method: 'POST', data: { address: queryAddress, raw: 'old unknown mail', created_at: OLD },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cleanType === 'sendbox') {
|
||||||
|
await send(request, mailbox);
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
}
|
||||||
|
await call(request, '/admin/cleanup', {
|
||||||
|
method: 'POST', data: { cleanType, cleanDays: cleanType === 'sendbox' ? 0 : 1 },
|
||||||
|
});
|
||||||
|
if (['addressCreated', 'unboundAddress', 'emptyAddress'].includes(cleanType)) {
|
||||||
|
expect(await addressRow(request, mailbox)).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
const path = cleanType === 'sendbox' ? '/admin/sendbox' : '/admin/mails';
|
||||||
|
expect((await list(request, path, { address: queryAddress })).count).toBe(0);
|
||||||
|
expect(await addressRow(request, mailbox)).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('invalid cleanup requests keep their original error details', async ({ request }) => {
|
||||||
|
for (const { data, message } of [
|
||||||
|
{ data: { cleanType: 'invalid', cleanDays: 1 }, message: 'Operation failed: Invalid cleanType' },
|
||||||
|
{ data: { cleanType: 'mails', cleanDays: -1 }, message: 'Operation failed: Invalid cleanType or cleanDays' },
|
||||||
|
]) {
|
||||||
|
const response = await call(request, '/admin/cleanup', { method: 'POST', data }, 500);
|
||||||
|
expect(await response.text()).toBe(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
function signToken(payload: Record<string, unknown>) {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', 'e2e-test-secret-key')
|
||||||
|
.update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectRejected(request: APIRequestContext, jwt: string) {
|
||||||
|
for (const [method, path] of [
|
||||||
|
['GET', '/api/settings'],
|
||||||
|
['GET', '/api/mails?limit=20&offset=0'],
|
||||||
|
['GET', '/api/mail/1'],
|
||||||
|
['GET', '/api/parsed_mails?limit=20&offset=0'],
|
||||||
|
['GET', '/api/parsed_mail/1'],
|
||||||
|
['GET', '/api/sendbox?limit=20&offset=0'],
|
||||||
|
['GET', '/api/auto_reply'],
|
||||||
|
['POST', '/api/webhook/settings'],
|
||||||
|
['POST', '/api/attachment/get_url'],
|
||||||
|
['POST', '/api/address_change_password'],
|
||||||
|
['POST', '/api/request_send_mail_access'],
|
||||||
|
['POST', '/api/send_mail'],
|
||||||
|
['PATCH', '/api/mails/1/read'],
|
||||||
|
['DELETE', '/api/mails/1'],
|
||||||
|
['DELETE', '/api/sendbox/1'],
|
||||||
|
['DELETE', '/api/clear_inbox'],
|
||||||
|
['DELETE', '/api/clear_sent_items'],
|
||||||
|
['DELETE', '/api/delete_address'],
|
||||||
|
]) {
|
||||||
|
const response = await request.fetch(`${WORKER_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
...(method === 'POST' || method === 'PATCH' ? { data: {} } : {}),
|
||||||
|
});
|
||||||
|
expect(response.status(), `${method} ${path}`).toBe(401);
|
||||||
|
}
|
||||||
|
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||||
|
data: { credential: jwt },
|
||||||
|
});
|
||||||
|
expect(login.status()).toBe(401);
|
||||||
|
const send = await request.post(`${WORKER_URL}/external/api/send_mail`, {
|
||||||
|
headers: { 'x-lang': 'en' },
|
||||||
|
data: { token: jwt },
|
||||||
|
});
|
||||||
|
expect(send.status()).toBe(400);
|
||||||
|
expect(await send.text()).toBe('Failed to send mail Invalid address credential');
|
||||||
|
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
'x-user-token': signToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 60 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bind.status()).toBe(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deleted credentials cannot access or delete a recreated mailbox', async ({ request }) => {
|
||||||
|
const name = `credential${Date.now()}`;
|
||||||
|
const create = async () => {
|
||||||
|
const response = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||||
|
data: { name, domain: TEST_DOMAIN },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
const original = await create();
|
||||||
|
await deleteAddress(request, original.jwt);
|
||||||
|
await expectRejected(request, original.jwt);
|
||||||
|
|
||||||
|
const recreated = await create();
|
||||||
|
try {
|
||||||
|
expect(recreated.address).toBe(original.address);
|
||||||
|
expect(recreated.address_id).not.toBe(original.address_id);
|
||||||
|
await seedTestMail(request, recreated.address, { subject: 'New owner mail' });
|
||||||
|
await expectRejected(request, original.jwt);
|
||||||
|
const mails = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${recreated.jwt}` },
|
||||||
|
});
|
||||||
|
expect(mails.ok()).toBe(true);
|
||||||
|
expect((await mails.json()).count).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, recreated.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid numeric/string IDs work; missing, invalid and mismatched IDs are rejected', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'credential-id');
|
||||||
|
try {
|
||||||
|
for (const address_id of [mailbox.address_id, String(mailbox.address_id)]) {
|
||||||
|
const token = signToken({ address: mailbox.address, address_id });
|
||||||
|
const settings = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(settings.ok()).toBe(true);
|
||||||
|
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||||
|
data: { credential: token },
|
||||||
|
});
|
||||||
|
expect(login.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
for (const payload of [
|
||||||
|
{ address: mailbox.address },
|
||||||
|
...[0, -1, 1.5, true, null, '', '1e3', {}, Number.MAX_SAFE_INTEGER + 1]
|
||||||
|
.map(address_id => ({ address: mailbox.address, address_id })),
|
||||||
|
{ address: `other@${TEST_DOMAIN}`, address_id: mailbox.address_id },
|
||||||
|
{ address_id: mailbox.address_id },
|
||||||
|
]) {
|
||||||
|
const jwt = signToken(payload);
|
||||||
|
const response = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(response.status(), JSON.stringify(payload)).toBe(401);
|
||||||
|
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||||
|
data: { credential: jwt },
|
||||||
|
});
|
||||||
|
expect(login.status(), JSON.stringify(payload)).toBe(401);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, mailbox.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
test.describe('Address Password Login', () => {
|
test.describe('Address Password Login', () => {
|
||||||
test('set password then login with it', async ({ request }) => {
|
test('set password then login with it', async ({ request }) => {
|
||||||
const { jwt, address } = await createTestAddress(request, 'pwd-login');
|
const { jwt, address } = await createTestAddress(request, 'pwd-login');
|
||||||
|
const passwordHash = hashPassword('test-password-123');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set a password on the address
|
// Set a password on the address
|
||||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
data: { new_password: 'test-password-123' },
|
data: { new_password: passwordHash },
|
||||||
});
|
});
|
||||||
expect(changePwdRes.ok()).toBe(true);
|
expect(changePwdRes.ok()).toBe(true);
|
||||||
const changePwdBody = await changePwdRes.json();
|
const changePwdBody = await changePwdRes.json();
|
||||||
@@ -17,7 +18,7 @@ test.describe('Address Password Login', () => {
|
|||||||
|
|
||||||
// Login with the correct password
|
// Login with the correct password
|
||||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||||
data: { email: address, password: 'test-password-123' },
|
data: { email: address, password: passwordHash },
|
||||||
});
|
});
|
||||||
expect(loginRes.ok()).toBe(true);
|
expect(loginRes.ok()).toBe(true);
|
||||||
const loginBody = await loginRes.json();
|
const loginBody = await loginRes.json();
|
||||||
@@ -36,12 +37,13 @@ test.describe('Address Password Login', () => {
|
|||||||
|
|
||||||
test('login with wrong password returns 401', async ({ request }) => {
|
test('login with wrong password returns 401', async ({ request }) => {
|
||||||
const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
|
const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
|
||||||
|
const passwordHash = hashPassword('correct-password');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set a password
|
// Set a password
|
||||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
data: { new_password: 'correct-password' },
|
data: { new_password: passwordHash },
|
||||||
});
|
});
|
||||||
expect(changePwdRes.ok()).toBe(true);
|
expect(changePwdRes.ok()).toBe(true);
|
||||||
const changePwdBody = await changePwdRes.json();
|
const changePwdBody = await changePwdRes.json();
|
||||||
@@ -49,11 +51,117 @@ test.describe('Address Password Login', () => {
|
|||||||
|
|
||||||
// Login with wrong password
|
// Login with wrong password
|
||||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||||
data: { email: address, password: 'wrong-password' },
|
data: { email: address, password: hashPassword('wrong-password') },
|
||||||
});
|
});
|
||||||
expect(loginRes.status()).toBe(401);
|
expect(loginRes.status()).toBe(401);
|
||||||
} finally {
|
} finally {
|
||||||
await deleteAddress(request, jwt);
|
await deleteAddress(request, jwt);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admin reset stores frontend-hashed address password', async ({ request }) => {
|
||||||
|
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-admin-reset');
|
||||||
|
const plainPassword = `admin-reset-${Date.now()}`;
|
||||||
|
const passwordHash = hashPassword(plainPassword);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resetRes = await request.post(`${WORKER_URL}/admin/address/${address_id}/reset_password`, {
|
||||||
|
data: { password: passwordHash },
|
||||||
|
});
|
||||||
|
expect(resetRes.ok()).toBe(true);
|
||||||
|
await expect(resetRes.json()).resolves.toMatchObject({ success: true });
|
||||||
|
|
||||||
|
const plaintextLoginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||||
|
data: { email: address, password: plainPassword },
|
||||||
|
});
|
||||||
|
expect(plaintextLoginRes.status()).toBe(401);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||||
|
data: { email: address, password: passwordHash },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const loginBody = await loginRes.json();
|
||||||
|
expect(loginBody.jwt).toBeTruthy();
|
||||||
|
expect(loginBody.address).toBe(address);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin address list does not expose stored password hash', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'pwd-list-hidden');
|
||||||
|
const passwordHash = hashPassword('list-hidden-password');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
data: { new_password: passwordHash },
|
||||||
|
});
|
||||||
|
expect(changePwdRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const listRes = await request.get(
|
||||||
|
`${WORKER_URL}/admin/address?limit=10&offset=0&query=${encodeURIComponent(address)}`
|
||||||
|
);
|
||||||
|
expect(listRes.ok()).toBe(true);
|
||||||
|
const listBody = await listRes.json();
|
||||||
|
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
|
||||||
|
expect(listedAddress).toBeTruthy();
|
||||||
|
expect(listedAddress).not.toHaveProperty('password');
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user bind address list does not expose stored password hash', async ({ request }) => {
|
||||||
|
const userEmail = `pwd-bind-hidden-${Date.now()}@test.example.com`;
|
||||||
|
const userPasswordHash = hashPassword('bind-hidden-user-password');
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'pwd-bind-hidden');
|
||||||
|
const addressPasswordHash = hashPassword('bind-hidden-address-password');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email: userEmail, password: userPasswordHash },
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email: userEmail, password: userPasswordHash },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt: userJwt } = await loginRes.json();
|
||||||
|
|
||||||
|
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
data: { new_password: addressPasswordHash },
|
||||||
|
});
|
||||||
|
expect(changePwdRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
'x-user-token': userJwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bindRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const listRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
});
|
||||||
|
expect(listRes.ok()).toBe(true);
|
||||||
|
const listBody = await listRes.json();
|
||||||
|
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
|
||||||
|
expect(listedAddress).toBeTruthy();
|
||||||
|
expect(listedAddress).not.toHaveProperty('password');
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
|
||||||
|
test.describe('Admin Config and D1 Storage', () => {
|
||||||
|
test('reports database size in the database status response', async ({ request }) => {
|
||||||
|
const response = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.database_size).toEqual(expect.any(Number));
|
||||||
|
expect(body.database_size).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves and retrieves a namespaced config value', async ({ request }) => {
|
||||||
|
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { key: 'd1_storage_plan', value: 'free' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveResponse.ok()).toBe(true);
|
||||||
|
expect(await saveResponse.json()).toEqual({
|
||||||
|
success: true,
|
||||||
|
key: 'd1_storage_plan',
|
||||||
|
value: 'free',
|
||||||
|
});
|
||||||
|
|
||||||
|
const getResponse = await request.get(`${WORKER_URL}/admin/config/d1_storage_plan`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getResponse.ok()).toBe(true);
|
||||||
|
expect(await getResponse.json()).toEqual({
|
||||||
|
key: 'd1_storage_plan',
|
||||||
|
value: 'free',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps config values isolated from internal settings', async ({ request }) => {
|
||||||
|
const versionBeforeResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
const versionBefore = (await versionBeforeResponse.json()).current_db_version;
|
||||||
|
|
||||||
|
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { key: 'db_version', value: 'shadow-version' },
|
||||||
|
});
|
||||||
|
expect(saveResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const versionAfterResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect((await versionAfterResponse.json()).current_db_version).toBe(versionBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid keys and non-string values', async ({ request }) => {
|
||||||
|
const invalidKeyResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { key: 'invalid key', value: 'value' },
|
||||||
|
});
|
||||||
|
expect(invalidKeyResponse.status()).toBe(400);
|
||||||
|
|
||||||
|
const invalidValueResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { key: 'valid_key', value: 1 },
|
||||||
|
});
|
||||||
|
expect(invalidValueResponse.status()).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,4 +16,116 @@ test.describe('Admin New Address', () => {
|
|||||||
expect(body.address_id).toBeGreaterThan(0);
|
expect(body.address_id).toBeGreaterThan(0);
|
||||||
expect(typeof body.address_id).toBe('number');
|
expect(typeof body.address_id).toBe('number');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes uppercase configured prefix and domain', async ({ request }) => {
|
||||||
|
const uniqueName = `admincase${Date.now()}`;
|
||||||
|
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name: uniqueName, domain: TEST_DOMAIN.toUpperCase(), enablePrefix: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
|
||||||
|
expect(body.jwt).toBeTruthy();
|
||||||
|
expect(body.address_id).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to domains when default domains is empty', async ({ request }) => {
|
||||||
|
const uniqueName = `fallback${Date.now().toString(36)}`;
|
||||||
|
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||||
|
data: { name: uniqueName },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
|
||||||
|
expect(body.jwt).toBeTruthy();
|
||||||
|
expect(body.address_id).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes user role domains and prefix', async ({ request }) => {
|
||||||
|
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const email = `role-case-${suffix}@${TEST_DOMAIN}`;
|
||||||
|
const password = `role-pass-${suffix}`;
|
||||||
|
const name = `rolecase${suffix}`;
|
||||||
|
|
||||||
|
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(createUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
|
||||||
|
params: { limit: '20', offset: '0', query: email },
|
||||||
|
});
|
||||||
|
expect(usersRes.ok()).toBe(true);
|
||||||
|
const usersBody = await usersRes.json();
|
||||||
|
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
|
||||||
|
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
data: { user_id: user.id, role_text: 'case-role' },
|
||||||
|
});
|
||||||
|
expect(updateRoleRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt: userJwt } = await loginRes.json();
|
||||||
|
|
||||||
|
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
data: { name },
|
||||||
|
});
|
||||||
|
expect(createAddressRes.ok()).toBe(true);
|
||||||
|
const addressBody = await createAddressRes.json();
|
||||||
|
|
||||||
|
expect(addressBody.address).toBe(`role${name}@${TEST_DOMAIN}`);
|
||||||
|
expect(addressBody.jwt).toBeTruthy();
|
||||||
|
expect(addressBody.address_id).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to default domains when user role domains is empty', async ({ request }) => {
|
||||||
|
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const email = `empty-role-${suffix}@${TEST_DOMAIN}`;
|
||||||
|
const password = `empty-role-pass-${suffix}`;
|
||||||
|
const name = `emptyrole${suffix}`;
|
||||||
|
|
||||||
|
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(createUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
|
||||||
|
params: { limit: '20', offset: '0', query: email },
|
||||||
|
});
|
||||||
|
expect(usersRes.ok()).toBe(true);
|
||||||
|
const usersBody = await usersRes.json();
|
||||||
|
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
|
||||||
|
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
data: { user_id: user.id, role_text: 'empty-role' },
|
||||||
|
});
|
||||||
|
expect(updateRoleRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt: userJwt } = await loginRes.json();
|
||||||
|
|
||||||
|
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
data: { name },
|
||||||
|
});
|
||||||
|
expect(createAddressRes.ok()).toBe(true);
|
||||||
|
const addressBody = await createAddressRes.json();
|
||||||
|
|
||||||
|
expect(addressBody.address).toBe(`empty${name}@${TEST_DOMAIN}`);
|
||||||
|
expect(addressBody.jwt).toBeTruthy();
|
||||||
|
expect(addressBody.address_id).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ async function seedTestMailWithReply(
|
|||||||
text,
|
text,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
if (!res.ok()) {
|
if (!res.ok()) {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const listMails = async (request: APIRequestContext, address: string) => {
|
||||||
|
const response = await request.get(`${WORKER_URL}/admin/mails`, {
|
||||||
|
params: { address, limit: '100', offset: '0' },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Bounded cleanup', () => {
|
||||||
|
test('cleans at most one batch and continues on the next run', async ({ request }) => {
|
||||||
|
const address = `cleanup-batch-${Date.now()}@test.example.com`;
|
||||||
|
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
|
||||||
|
request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
|
data: {
|
||||||
|
address,
|
||||||
|
raw: 'old cleanup mail',
|
||||||
|
message_id: `<cleanup-old-${Date.now()}-${index}@test>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
));
|
||||||
|
expect(seedResponses.every((response) => response.ok())).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
|
const firstCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||||
|
data: { cleanType: 'mails', cleanDays: 0 },
|
||||||
|
});
|
||||||
|
expect(firstCleanup.ok()).toBe(true);
|
||||||
|
|
||||||
|
const afterFirstCleanup = await listMails(request, address);
|
||||||
|
expect(afterFirstCleanup.count).toBe(1);
|
||||||
|
|
||||||
|
const secondCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||||
|
data: { cleanType: 'mails', cleanDays: 0 },
|
||||||
|
});
|
||||||
|
expect(secondCleanup.ok()).toBe(true);
|
||||||
|
|
||||||
|
const afterSecondCleanup = await listMails(request, address);
|
||||||
|
expect(afterSecondCleanup.count).toBe(0);
|
||||||
|
|
||||||
|
const recentMailResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
|
data: {
|
||||||
|
address,
|
||||||
|
raw: 'recent cleanup mail',
|
||||||
|
message_id: `<cleanup-recent-${Date.now()}@test>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(recentMailResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||||
|
data: { cleanType: 'mails', cleanDays: 1 },
|
||||||
|
});
|
||||||
|
expect(recentCleanup.ok()).toBe(true);
|
||||||
|
|
||||||
|
const recentMails = await listMails(request, address);
|
||||||
|
expect(recentMails.count).toBe(1);
|
||||||
|
expect(recentMails.results[0].raw).toBe('recent cleanup mail');
|
||||||
|
await request.delete(`${WORKER_URL}/admin/mails/${recentMails.results[0].id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes one address batch and its related data', async ({ request }) => {
|
||||||
|
const oldAddress = await createTestAddress(request, 'cleanup-old');
|
||||||
|
|
||||||
|
const seedResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
|
data: {
|
||||||
|
address: oldAddress.address,
|
||||||
|
raw: 'address cleanup mail',
|
||||||
|
message_id: `<cleanup-address-${Date.now()}@test>`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(seedResponse.ok()).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
|
const cleanupResponse = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||||
|
data: { cleanType: 'addressCreated', cleanDays: 0 },
|
||||||
|
});
|
||||||
|
expect(cleanupResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const oldAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
|
||||||
|
params: { query: oldAddress.address, limit: '20', offset: '0' },
|
||||||
|
});
|
||||||
|
expect(oldAddressResponse.ok()).toBe(true);
|
||||||
|
expect((await oldAddressResponse.json()).count).toBe(0);
|
||||||
|
expect((await listMails(request, oldAddress.address)).count).toBe(0);
|
||||||
|
|
||||||
|
const recentAddress = await createTestAddress(request, 'cleanup-recent');
|
||||||
|
try {
|
||||||
|
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||||
|
data: { cleanType: 'addressCreated', cleanDays: 1 },
|
||||||
|
});
|
||||||
|
expect(recentCleanup.ok()).toBe(true);
|
||||||
|
|
||||||
|
const recentAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
|
||||||
|
params: { query: recentAddress.address, limit: '20', offset: '0' },
|
||||||
|
});
|
||||||
|
expect(recentAddressResponse.ok()).toBe(true);
|
||||||
|
expect((await recentAddressResponse.json()).count).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, recentAddress.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
TEST_DOMAIN,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_SETTINGS = {
|
||||||
|
blockList: [],
|
||||||
|
sendBlockList: [],
|
||||||
|
verifiedAddressList: [],
|
||||||
|
fromBlockList: [],
|
||||||
|
noLimitSendAddressList: [],
|
||||||
|
emailRuleSettings: {},
|
||||||
|
addressCreationSettings: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resetAccountSettings(request: APIRequestContext) {
|
||||||
|
const res = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: DEFAULT_ACCOUNT_SETTINGS,
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Email forward domain normalization', () => {
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
await resetAccountSettings(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes uppercase forwarding rule and recipient domains', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'forward-case');
|
||||||
|
const forwardAddress = 'forward-target@test.example.com';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
...DEFAULT_ACCOUNT_SETTINGS,
|
||||||
|
emailRuleSettings: {
|
||||||
|
emailForwardingList: [{
|
||||||
|
domains: [TEST_DOMAIN.toUpperCase()],
|
||||||
|
forward: forwardAddress,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saveRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const to = address.replace(`@${TEST_DOMAIN}`, `@${TEST_DOMAIN.toUpperCase()}`);
|
||||||
|
const subject = `forward-case-${Date.now()}`;
|
||||||
|
const raw = [
|
||||||
|
`From: sender@test.example.com`,
|
||||||
|
`To: ${to}`,
|
||||||
|
`Subject: ${subject}`,
|
||||||
|
`Message-ID: <${subject}@test>`,
|
||||||
|
`MIME-Version: 1.0`,
|
||||||
|
`Content-Type: text/plain; charset=utf-8`,
|
||||||
|
``,
|
||||||
|
`Forward domain normalization test`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
|
data: { from: 'sender@test.example.com', to, raw },
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.forwardedTo).toEqual([forwardAddress]);
|
||||||
|
|
||||||
|
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(mailsRes.ok()).toBe(true);
|
||||||
|
const mailsBody = await mailsRes.json();
|
||||||
|
expect(mailsBody.results.some((mail: { address: string; raw: string }) => {
|
||||||
|
return mail.address === address && mail.raw.includes(subject);
|
||||||
|
})).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not forward when only the domain suffix string matches', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'forward-boundary');
|
||||||
|
const forwardAddress = 'forward-boundary-target@test.example.com';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
...DEFAULT_ACCOUNT_SETTINGS,
|
||||||
|
emailRuleSettings: {
|
||||||
|
emailForwardingList: [{
|
||||||
|
domains: [TEST_DOMAIN],
|
||||||
|
forward: forwardAddress,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saveRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const to = address.replace(`@${TEST_DOMAIN}`, `@evil${TEST_DOMAIN}`);
|
||||||
|
const subject = `forward-boundary-${Date.now()}`;
|
||||||
|
const raw = [
|
||||||
|
`From: sender@test.example.com`,
|
||||||
|
`To: ${to}`,
|
||||||
|
`Subject: ${subject}`,
|
||||||
|
`Message-ID: <${subject}@test>`,
|
||||||
|
`MIME-Version: 1.0`,
|
||||||
|
`Content-Type: text/plain; charset=utf-8`,
|
||||||
|
``,
|
||||||
|
`Forward domain boundary test`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
|
data: { from: 'sender@test.example.com', to, raw },
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.forwardedTo).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps blank forwarding rule domain as catch-all', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'forward-catch-all');
|
||||||
|
const forwardAddress = 'forward-catch-all-target@test.example.com';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
...DEFAULT_ACCOUNT_SETTINGS,
|
||||||
|
emailRuleSettings: {
|
||||||
|
emailForwardingList: [{
|
||||||
|
domains: ['', 'not-the-domain.example.com'],
|
||||||
|
forward: forwardAddress,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saveRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const subject = `forward-catch-all-${Date.now()}`;
|
||||||
|
const raw = [
|
||||||
|
`From: sender@test.example.com`,
|
||||||
|
`To: ${address}`,
|
||||||
|
`Subject: ${subject}`,
|
||||||
|
`Message-ID: <${subject}@test>`,
|
||||||
|
`MIME-Version: 1.0`,
|
||||||
|
`Content-Type: text/plain; charset=utf-8`,
|
||||||
|
``,
|
||||||
|
`Forward catch-all domain test`,
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
|
data: { from: 'sender@test.example.com', to: address, raw },
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.forwardedTo).toEqual([forwardAddress]);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ test.describe('Health & Settings', () => {
|
|||||||
const settings = await res.json();
|
const settings = await res.json();
|
||||||
expect(settings.domains).toContain('test.example.com');
|
expect(settings.domains).toContain('test.example.com');
|
||||||
expect(settings.defaultDomains).toContain('test.example.com');
|
expect(settings.defaultDomains).toContain('test.example.com');
|
||||||
|
expect(settings.prefix).toBe('tmp');
|
||||||
expect(settings.enableSendMail).toBe(true);
|
expect(settings.enableSendMail).toBe(true);
|
||||||
expect(settings.enableUserCreateEmail).toBe(true);
|
expect(settings.enableUserCreateEmail).toBe(true);
|
||||||
expect(settings.enableUserDeleteEmail).toBe(true);
|
expect(settings.enableUserDeleteEmail).toBe(true);
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } 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.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
|
||||||
|
|
||||||
@@ -27,6 +19,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(res.status()).toBe(401);
|
expect(res.status()).toBe(401);
|
||||||
|
expect(await res.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID', message: expect.any(String) });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,14 +103,14 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
|||||||
// Set a password
|
// Set a password
|
||||||
await request.post(`${WORKER_URL}/api/address_change_password`, {
|
await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
data: { new_password: 'addr-pass-123' },
|
data: { new_password: hashPassword('addr-pass-123') },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login with cf_token field present but empty
|
// Login with cf_token field present but empty
|
||||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||||
data: {
|
data: {
|
||||||
email: address,
|
email: address,
|
||||||
password: 'addr-pass-123',
|
password: hashPassword('addr-pass-123'),
|
||||||
cf_token: ''
|
cf_token: ''
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => {
|
|||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
|
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
expect(seedRes.ok()).toBe(true);
|
expect(seedRes.ok()).toBe(true);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
|
||||||
test.describe('Mail Detail', () => {
|
test.describe('Mail Detail', () => {
|
||||||
test('fetch a single mail by ID', async ({ request }) => {
|
test('fetch a single mail by ID', async ({ request }) => {
|
||||||
const { jwt, address } = await createTestAddress(request, 'detail-get');
|
const { jwt, address } = await createTestAddress(request, 'detail-get');
|
||||||
@@ -53,3 +55,46 @@ test.describe('Mail Detail', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Admin Mail Detail', () => {
|
||||||
|
test('fetch a single mail by ID without a mailbox JWT', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'admin-detail-get');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedTestMail(request, address, {
|
||||||
|
subject: 'Admin Detail Test',
|
||||||
|
from: 'admin-detail@test.example.com',
|
||||||
|
text: 'Hello admin detail',
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const detailRes = await request.get(`${WORKER_URL}/admin/mails/${mailId}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(detailRes.ok()).toBe(true);
|
||||||
|
const mail = await detailRes.json();
|
||||||
|
expect(mail.id).toBe(mailId);
|
||||||
|
expect(mail.address).toBe(address);
|
||||||
|
expect(mail.source).toBe('admin-detail@test.example.com');
|
||||||
|
expect(mail.raw).toContain('Admin Detail Test');
|
||||||
|
expect(mail.raw_blob).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetch non-existent mail returns null', async ({ request }) => {
|
||||||
|
const res = await request.get(`${WORKER_URL}/admin/mails/99999999`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
expect(await res.json()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
seedTestMail,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const headers = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||||
|
|
||||||
|
test.describe('Mail read status', () => {
|
||||||
|
test('keeps historical mail read and switches one new mail state', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'mail-read');
|
||||||
|
try {
|
||||||
|
const historical = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
|
data: {
|
||||||
|
address: mailbox.address,
|
||||||
|
source: 'sender@test.example.com',
|
||||||
|
raw: 'From: sender@test.example.com\r\nSubject: Historical\r\n\r\nHistorical',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(historical.ok()).toBe(true);
|
||||||
|
await seedTestMail(request, mailbox.address, { subject: 'New unread mail' });
|
||||||
|
|
||||||
|
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
});
|
||||||
|
const mails = (await list.json()).results;
|
||||||
|
expect(mails.find((mail: any) => mail.raw.includes('Historical')).is_unread).toBeNull();
|
||||||
|
const unreadMail = mails.find((mail: any) => mail.raw.includes('New unread mail'));
|
||||||
|
expect(unreadMail.is_unread).toBe(1);
|
||||||
|
|
||||||
|
const markRead = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
data: { isUnread: false },
|
||||||
|
});
|
||||||
|
expect((await markRead.json()).success).toBe(true);
|
||||||
|
|
||||||
|
const updated = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
});
|
||||||
|
const updatedMail = (await updated.json()).results.find((mail: any) => mail.id === unreadMail.id);
|
||||||
|
expect(updatedMail.is_unread).toBe(0);
|
||||||
|
|
||||||
|
const markUnread = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
data: { isUnread: true },
|
||||||
|
});
|
||||||
|
expect((await markUnread.json()).success).toBe(true);
|
||||||
|
|
||||||
|
const restored = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
});
|
||||||
|
const restoredMail = (await restored.json()).results.find((mail: any) => mail.id === unreadMail.id);
|
||||||
|
expect(restoredMail.is_unread).toBe(1);
|
||||||
|
|
||||||
|
const invalid = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
data: { isUnread: 'yes' },
|
||||||
|
});
|
||||||
|
expect(invalid.status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, mailbox.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopes the update and keeps disabled instances unchanged', async ({ request }) => {
|
||||||
|
const first = await createTestAddress(request, 'mail-read-first');
|
||||||
|
const second = await createTestAddress(request, 'mail-read-second');
|
||||||
|
try {
|
||||||
|
await seedTestMail(request, second.address, { subject: 'Second mail' });
|
||||||
|
const secondList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(second.jwt),
|
||||||
|
});
|
||||||
|
const secondMail = (await secondList.json()).results[0];
|
||||||
|
|
||||||
|
await request.patch(`${WORKER_URL}/api/mails/${secondMail.id}/read`, {
|
||||||
|
headers: headers(first.jwt),
|
||||||
|
data: { isUnread: false },
|
||||||
|
});
|
||||||
|
const unchanged = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(second.jwt),
|
||||||
|
});
|
||||||
|
expect((await unchanged.json()).results[0].is_unread).toBe(1);
|
||||||
|
|
||||||
|
const disabledMailbox = await createTestAddress(
|
||||||
|
request,
|
||||||
|
'mail-read-disabled',
|
||||||
|
'test.example.com',
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
);
|
||||||
|
const oldList = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(disabledMailbox.jwt),
|
||||||
|
});
|
||||||
|
expect(oldList.ok()).toBe(true);
|
||||||
|
const disabledUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/1/read`, {
|
||||||
|
headers: headers(disabledMailbox.jwt),
|
||||||
|
data: { isUnread: false },
|
||||||
|
});
|
||||||
|
expect(disabledUpdate.status()).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, first.jwt);
|
||||||
|
await deleteAddress(request, second.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
WORKER_URL_SITE_PASSWORD,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const SITE_HEADERS = { 'x-custom-auth': 'e2e-site-pass' };
|
||||||
|
const ADMIN_HEADERS = { ...SITE_HEADERS, 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
const futureExpiration = () => new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
|
||||||
|
const signTestToken = (payload: Record<string, unknown>, secret = 'e2e-site-password-secret') => {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenPayload = (role: string) => ({
|
||||||
|
user_id: 1,
|
||||||
|
user_role: role,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Redemption feature access boundaries', () => {
|
||||||
|
test('the disabled switch hides every user and Admin endpoint', async ({ request }) => {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL_ENV_OFF}/open_api/settings`);
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
expect((await settingsResponse.json()).enableRedeemCode).toBe(false);
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/query`, { data: { code: 'anything' } }),
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/result`, { data: { code: 'anything' } }),
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/redeem`, {
|
||||||
|
data: { code: 'anything', user_email: 'user@test.example.com' },
|
||||||
|
}),
|
||||||
|
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes?redeem_type=role`),
|
||||||
|
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/export?redeem_type=role&limit=1`),
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/batch`, {
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request.put(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`, {
|
||||||
|
data: {
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request.delete(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`),
|
||||||
|
];
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status()).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site password takes priority over otherwise public redemption APIs', async ({ request }) => {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL_SITE_PASSWORD}/open_api/settings`);
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
expect(await settingsResponse.json()).toMatchObject({
|
||||||
|
needAuth: true,
|
||||||
|
enableRedeemCode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockedAdminResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||||
|
{
|
||||||
|
headers: { 'x-admin-auth': 'e2e-admin-pass' },
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(blockedAdminResponse.status()).toBe(401);
|
||||||
|
expect(await blockedAdminResponse.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
|
||||||
|
|
||||||
|
const createResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||||
|
{
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const code = (await createResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
for (const path of ['query', 'result', 'redeem']) {
|
||||||
|
const missingPassword = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
|
||||||
|
{ data: { code } },
|
||||||
|
);
|
||||||
|
expect(missingPassword.status()).toBe(401);
|
||||||
|
expect(await missingPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
|
||||||
|
const wrongPassword = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
|
||||||
|
{ headers: { 'x-custom-auth': 'wrong' }, data: { code } },
|
||||||
|
);
|
||||||
|
expect(wrongPassword.status()).toBe(401);
|
||||||
|
expect(await wrongPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
|
||||||
|
}
|
||||||
|
const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, {
|
||||||
|
headers: SITE_HEADERS,
|
||||||
|
data: { code },
|
||||||
|
});
|
||||||
|
expect(validPassword.ok()).toBe(true);
|
||||||
|
expect(await validPassword.json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=role`
|
||||||
|
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const row = (await listResponse.json()).results[0];
|
||||||
|
const deleteResponse = await request.delete(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(deleteResponse.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('special-address redemption preserves the configured address regex', async ({ request }) => {
|
||||||
|
const createResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||||
|
{
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: '',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const code = (await createResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/redeem`,
|
||||||
|
{
|
||||||
|
headers: SITE_HEADERS,
|
||||||
|
data: { code, name: 'blocked', domain: 'test.example.com' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(redeemResponse.status()).toBe(400);
|
||||||
|
|
||||||
|
const queryResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`,
|
||||||
|
{ headers: SITE_HEADERS, data: { code } },
|
||||||
|
);
|
||||||
|
expect(queryResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=address_prefix_once`
|
||||||
|
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const row = (await listResponse.json()).results[0];
|
||||||
|
const deleteResponse = await request.delete(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(deleteResponse.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Redemption Admin authentication', () => {
|
||||||
|
const baseUrl = `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes`;
|
||||||
|
const listUrl = `${baseUrl}?redeem_type=address_prefix_once&limit=100&offset=0`;
|
||||||
|
const codeData = () => ({
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: 'auth',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
});
|
||||||
|
const deniedCredentials: { name: string; headers: () => Record<string, string> }[] = [
|
||||||
|
{ name: 'site password alone', headers: () => ({}) },
|
||||||
|
{ name: 'wrong Admin password', headers: () => ({ 'x-admin-auth': 'wrong' }) },
|
||||||
|
{
|
||||||
|
name: 'mailbox JWT',
|
||||||
|
headers: () => ({
|
||||||
|
Authorization: `Bearer ${signTestToken({ address: 'mail@test.example.com', address_id: 1 })}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user account JWT',
|
||||||
|
headers: () => ({
|
||||||
|
'x-user-token': signTestToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 3600 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-Admin role token',
|
||||||
|
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('case-role')) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'forged Admin role token',
|
||||||
|
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('admin'), 'wrong-secret') }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expired Admin role token',
|
||||||
|
headers: () => ({
|
||||||
|
'x-user-access-token': signTestToken({ ...tokenPayload('admin'), exp: Math.floor(Date.now() / 1000) - 60 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin role token without expiration',
|
||||||
|
headers: () => ({ 'x-user-access-token': signTestToken({ user_id: 1, user_role: 'admin' }) }),
|
||||||
|
},
|
||||||
|
{ name: 'malformed role token', headers: () => ({ 'x-user-access-token': 'not-a-jwt' }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const credentials of deniedCredentials) {
|
||||||
|
test(`rejects ${credentials.name} on all five endpoints without changing data`, async ({ request }) => {
|
||||||
|
const original = codeData();
|
||||||
|
const created = await request.post(`${baseUrl}/batch`, { headers: ADMIN_HEADERS, data: original });
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const code = (await created.json()).codes[0] as string;
|
||||||
|
const beforeResponse = await request.get(listUrl, { headers: ADMIN_HEADERS });
|
||||||
|
expect(beforeResponse.ok()).toBe(true);
|
||||||
|
const before = await beforeResponse.json();
|
||||||
|
const row = before.results.find((item: { code: string }) => item.code === code);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
try {
|
||||||
|
const headers = { ...SITE_HEADERS, ...credentials.headers() };
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request.get(listUrl, { headers }),
|
||||||
|
request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers }),
|
||||||
|
request.post(`${baseUrl}/batch`, { headers, data: original }),
|
||||||
|
request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'changed' } }),
|
||||||
|
request.delete(`${baseUrl}/${row.id}`, { headers }),
|
||||||
|
]);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status(), response.url()).toBe(401);
|
||||||
|
expect(await response.text()).not.toContain(code);
|
||||||
|
}
|
||||||
|
const after = await request.get(listUrl, { headers: ADMIN_HEADERS });
|
||||||
|
expect(after.ok()).toBe(true);
|
||||||
|
expect(await after.json()).toEqual(before);
|
||||||
|
} finally {
|
||||||
|
const deleted = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
|
||||||
|
expect(deleted.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const authType of ['password', 'role token'] as const) {
|
||||||
|
test(`accepts a valid Admin ${authType} for all five endpoints`, async ({ request }) => {
|
||||||
|
const headers: Record<string, string> = authType === 'password'
|
||||||
|
? ADMIN_HEADERS
|
||||||
|
: { ...SITE_HEADERS, 'x-user-access-token': signTestToken(tokenPayload('admin')) };
|
||||||
|
const original = codeData();
|
||||||
|
const created = await request.post(`${baseUrl}/batch`, { headers, data: original });
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const code = (await created.json()).codes[0] as string;
|
||||||
|
const listResponse = await request.get(listUrl, { headers });
|
||||||
|
expect(listResponse.ok()).toBe(true);
|
||||||
|
const row = (await listResponse.json()).results.find((item: { code: string }) => item.code === code);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
let deleted = false;
|
||||||
|
try {
|
||||||
|
const exported = await request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers });
|
||||||
|
expect(exported.ok()).toBe(true);
|
||||||
|
expect(exported.headers()['content-type']).toContain('text/csv');
|
||||||
|
expect(await exported.text()).toContain(code);
|
||||||
|
|
||||||
|
const updated = await request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'updated' } });
|
||||||
|
expect(updated.ok()).toBe(true);
|
||||||
|
const afterUpdate = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
|
||||||
|
expect(afterUpdate.ok()).toBe(true);
|
||||||
|
expect((await afterUpdate.json()).results).toEqual([
|
||||||
|
expect.objectContaining({ id: row.id, code, value: 'updated' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const deletion = await request.delete(`${baseUrl}/${row.id}`, { headers });
|
||||||
|
expect(deletion.ok()).toBe(true);
|
||||||
|
deleted = true;
|
||||||
|
const afterDelete = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
|
||||||
|
expect(afterDelete.ok()).toBe(true);
|
||||||
|
expect(await afterDelete.json()).toEqual({ results: [], count: 0 });
|
||||||
|
} finally {
|
||||||
|
if (!deleted) {
|
||||||
|
const cleanup = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
|
||||||
|
expect(cleanup.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { deleteAddress, WORKER_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
const MAX_BATCH_SIZE = 500;
|
||||||
|
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||||
|
|
||||||
|
async function createCodes(
|
||||||
|
request: APIRequestContext,
|
||||||
|
{
|
||||||
|
count = 1,
|
||||||
|
type = 'role',
|
||||||
|
value = 'case-role',
|
||||||
|
enabled = true,
|
||||||
|
expiresAt = new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
}: {
|
||||||
|
count?: unknown;
|
||||||
|
type?: string;
|
||||||
|
value?: unknown;
|
||||||
|
enabled?: unknown;
|
||||||
|
expiresAt?: unknown;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count,
|
||||||
|
redeem_type: type,
|
||||||
|
value,
|
||||||
|
enabled,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCodes(
|
||||||
|
request: APIRequestContext,
|
||||||
|
type: RedeemType,
|
||||||
|
query = '',
|
||||||
|
) {
|
||||||
|
const response = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}&limit=100&offset=0`
|
||||||
|
+ (query ? `&query=${encodeURIComponent(query)}` : ''),
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCode(request: APIRequestContext, id: number) {
|
||||||
|
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Redemption code Admin API', () => {
|
||||||
|
test('rejects malformed JSON when creating codes', async ({ request }) => {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: { ...ADMIN_HEADERS, 'content-type': 'application/json' },
|
||||||
|
data: '{',
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports the enabled switch and exposes the migrated table', async ({ request }) => {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
expect((await settingsResponse.json()).enableRedeemCode).toBe(true);
|
||||||
|
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=role&limit=20&offset=0`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(listResponse.ok()).toBe(true);
|
||||||
|
await expect(listResponse.json()).resolves.toMatchObject({
|
||||||
|
results: expect.any(Array),
|
||||||
|
count: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch generates UUIDs, filters by type, updates, and deletes', async ({ request }) => {
|
||||||
|
const createResponse = await createCodes(request, { count: 2 });
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const created = await createResponse.json();
|
||||||
|
expect(created).toMatchObject({ success: true, created: 2 });
|
||||||
|
expect(created.codes).toHaveLength(2);
|
||||||
|
expect(new Set(created.codes).size).toBe(2);
|
||||||
|
expect(created.codes.every((code: string) => UUID_V4_PATTERN.test(code))).toBe(true);
|
||||||
|
const [firstCode, secondCode] = created.codes as string[];
|
||||||
|
const queryPrefix = firstCode.slice(0, 8);
|
||||||
|
|
||||||
|
const roleList = await listCodes(request, 'role', queryPrefix);
|
||||||
|
expect(roleList.count).toBe(1);
|
||||||
|
expect(roleList.results[0]).toMatchObject({
|
||||||
|
code: firstCode,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: 1,
|
||||||
|
redeemed: false,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
const balanceList = await listCodes(request, 'send_balance', queryPrefix);
|
||||||
|
expect(balanceList.count).toBe(0);
|
||||||
|
|
||||||
|
const firstId = roleList.results[0].id as number;
|
||||||
|
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstId}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'empty-role',
|
||||||
|
enabled: false,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const updatedList = await listCodes(request, 'role', firstCode);
|
||||||
|
expect(updatedList.count).toBe(1);
|
||||||
|
expect(updatedList.results[0]).toMatchObject({
|
||||||
|
id: firstId,
|
||||||
|
code: firstCode,
|
||||||
|
value: 'empty-role',
|
||||||
|
enabled: 0,
|
||||||
|
});
|
||||||
|
expect(updatedList.results[0].expires_at).toEqual(expect.any(String));
|
||||||
|
|
||||||
|
const secondList = await listCodes(request, 'role', secondCode);
|
||||||
|
await deleteCode(request, firstId);
|
||||||
|
await deleteCode(request, secondList.results[0].id);
|
||||||
|
expect((await listCodes(request, 'role', firstCode)).count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes typed values and accepts both empty and maximum usable prefixes', async ({ request }) => {
|
||||||
|
const emptyResponse = await createCodes(request, {
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
value: ' ',
|
||||||
|
});
|
||||||
|
expect(emptyResponse.ok()).toBe(true);
|
||||||
|
const emptyCode = (await emptyResponse.json()).codes[0] as string;
|
||||||
|
const maxResponse = await createCodes(request, {
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
value: ` ${'A'.repeat(29)} `,
|
||||||
|
});
|
||||||
|
expect(maxResponse.ok()).toBe(true);
|
||||||
|
const maxCode = (await maxResponse.json()).codes[0] as string;
|
||||||
|
const balanceResponse = await createCodes(request, {
|
||||||
|
type: 'send_balance',
|
||||||
|
value: ' 42 ',
|
||||||
|
});
|
||||||
|
expect(balanceResponse.ok()).toBe(true);
|
||||||
|
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const emptyRow = (await listCodes(request, 'address_prefix_once', emptyCode)).results[0];
|
||||||
|
const maxRow = (await listCodes(request, 'address_prefix_once', maxCode)).results[0];
|
||||||
|
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||||
|
expect(emptyRow.value).toBe('');
|
||||||
|
expect(maxRow.value).toBe('a'.repeat(29));
|
||||||
|
expect(balanceRow.value).toBe('42');
|
||||||
|
await deleteCode(request, emptyRow.id);
|
||||||
|
await deleteCode(request, maxRow.id);
|
||||||
|
await deleteCode(request, balanceRow.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects every invalid batch shape and typed business value', async ({ request }) => {
|
||||||
|
const invalidRequests = [
|
||||||
|
{ type: 'unknown', value: 'x' },
|
||||||
|
{ type: 'role', value: '' },
|
||||||
|
{ type: 'role', value: 'missing-role' },
|
||||||
|
{ type: 'role', value: 'admin' },
|
||||||
|
{ type: 'send_balance', value: '0' },
|
||||||
|
{ type: 'send_balance', value: '-1' },
|
||||||
|
{ type: 'send_balance', value: '1.5' },
|
||||||
|
{ type: 'send_balance', value: '1000000001' },
|
||||||
|
{ type: 'address_prefix_once', value: 'bad-' },
|
||||||
|
{ type: 'address_prefix_once', value: 'a'.repeat(30) },
|
||||||
|
{ count: 0 },
|
||||||
|
{ count: -1 },
|
||||||
|
{ count: 1.5 },
|
||||||
|
{ count: MAX_BATCH_SIZE + 1 },
|
||||||
|
{ count: '1' },
|
||||||
|
{ count: null },
|
||||||
|
{ enabled: 'yes' },
|
||||||
|
{ value: 1 },
|
||||||
|
{ expiresAt: null },
|
||||||
|
{ expiresAt: '' },
|
||||||
|
{ expiresAt: 'not-a-date' },
|
||||||
|
{ expiresAt: new Date(Date.now() - 60_000).toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const invalidRequest of invalidRequests) {
|
||||||
|
const response = await createCodes(request, invalidRequest);
|
||||||
|
expect(response.status(), JSON.stringify(invalidRequest)).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid updates and IDs', async ({ request }) => {
|
||||||
|
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
const createResponse = await createCodes(request, { count: 2 });
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const [firstCode, secondCode] = (await createResponse.json()).codes as string[];
|
||||||
|
const firstRow = (await listCodes(request, 'role', firstCode)).results[0];
|
||||||
|
const secondRow = (await listCodes(request, 'role', secondCode)).results[0];
|
||||||
|
|
||||||
|
const invalidId = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { redeem_type: 'role', value: 'case-role', enabled: true, expires_at: expiresAt },
|
||||||
|
});
|
||||||
|
expect(invalidId.status()).toBe(400);
|
||||||
|
|
||||||
|
const invalidRole = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { redeem_type: 'role', value: 'admin', enabled: true, expires_at: expiresAt },
|
||||||
|
});
|
||||||
|
expect(invalidRole.status()).toBe(400);
|
||||||
|
|
||||||
|
const wrongType = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
redeem_type: 'send_balance',
|
||||||
|
value: '1',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrongType.status()).toBe(404);
|
||||||
|
|
||||||
|
const invalidDelete = await request.delete(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(invalidDelete.status()).toBe(400);
|
||||||
|
|
||||||
|
await deleteCode(request, firstRow.id);
|
||||||
|
await deleteCode(request, secondRow.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update a redeemed code', async ({ request }) => {
|
||||||
|
let code = '';
|
||||||
|
let addressResult: { jwt?: string } | undefined;
|
||||||
|
try {
|
||||||
|
const createResponse = await createCodes(request, {
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
value: 'locked',
|
||||||
|
});
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
code = (await createResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(redeemResponse.ok()).toBe(true);
|
||||||
|
addressResult = await redeemResponse.json();
|
||||||
|
|
||||||
|
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||||
|
expect(row.redeemed).toBe(true);
|
||||||
|
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||||
|
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: 'changed',
|
||||||
|
enabled: false,
|
||||||
|
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateResponse.status()).toBe(409);
|
||||||
|
|
||||||
|
const unchanged = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||||
|
expect(unchanged).toMatchObject({
|
||||||
|
code,
|
||||||
|
value: 'locked',
|
||||||
|
enabled: 1,
|
||||||
|
redeemed: true,
|
||||||
|
});
|
||||||
|
await deleteCode(request, row.id);
|
||||||
|
} finally {
|
||||||
|
if (addressResult?.jwt) await deleteAddress(request, addressResult.jwt);
|
||||||
|
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||||
|
if (row) await deleteCode(request, row.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports one selected type with its business columns and enforces the limit', async ({ request }) => {
|
||||||
|
const roleResponse = await createCodes(request, {});
|
||||||
|
expect(roleResponse.ok()).toBe(true);
|
||||||
|
const roleCode = (await roleResponse.json()).codes[0] as string;
|
||||||
|
const balanceResponse = await createCodes(request, {
|
||||||
|
type: 'send_balance',
|
||||||
|
value: '25',
|
||||||
|
});
|
||||||
|
expect(balanceResponse.ok()).toBe(true);
|
||||||
|
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const roleExport = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=role&limit=100`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(roleExport.ok()).toBe(true);
|
||||||
|
expect(roleExport.headers()['content-type']).toContain('text/csv');
|
||||||
|
expect(roleExport.headers()['content-disposition']).toContain('redeem-codes-role.csv');
|
||||||
|
const roleCsv = await roleExport.text();
|
||||||
|
expect(roleCsv).toContain('role,redeemed_user_id');
|
||||||
|
expect(roleCsv).toContain(roleCode);
|
||||||
|
expect(roleCsv).not.toContain(balanceCode);
|
||||||
|
|
||||||
|
const balanceExport = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=send_balance&limit=100`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const balanceCsv = await balanceExport.text();
|
||||||
|
expect(balanceCsv).toContain('amount,target_address');
|
||||||
|
expect(balanceCsv).toContain(balanceCode);
|
||||||
|
expect(balanceCsv).not.toContain(roleCode);
|
||||||
|
|
||||||
|
for (const query of [
|
||||||
|
'redeem_type=unknown&limit=1',
|
||||||
|
'redeem_type=role&limit=0',
|
||||||
|
'redeem_type=role&limit=10001',
|
||||||
|
'redeem_type=role&limit=1x',
|
||||||
|
'redeem_type=role',
|
||||||
|
]) {
|
||||||
|
const response = await request.get(`${WORKER_URL}/admin/redeem_codes/export?${query}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.status(), query).toBe(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRow = (await listCodes(request, 'role', roleCode)).results[0];
|
||||||
|
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||||
|
await deleteCode(request, roleRow.id);
|
||||||
|
await deleteCode(request, balanceRow.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
getAddressSender,
|
||||||
|
hashPassword,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
|
||||||
|
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||||
|
type AddressRedeemResult = {
|
||||||
|
type: 'address_prefix_once';
|
||||||
|
address: string;
|
||||||
|
address_id: number;
|
||||||
|
jwt: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueValue = (label: string) => (
|
||||||
|
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createCode(
|
||||||
|
request: APIRequestContext,
|
||||||
|
type: RedeemType,
|
||||||
|
value: string,
|
||||||
|
options: { enabled?: boolean; expiresAt?: string } = {},
|
||||||
|
) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: type,
|
||||||
|
value,
|
||||||
|
enabled: options.enabled ?? true,
|
||||||
|
expires_at: options.expiresAt ?? new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.codes).toHaveLength(1);
|
||||||
|
return body.codes[0] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||||
|
const response = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||||
|
+ `&limit=100&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.results.find((row: { code: string }) => row.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||||
|
const row = await listCode(request, type, code);
|
||||||
|
if (!row) return;
|
||||||
|
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryCode(request: APIRequestContext, code: unknown) {
|
||||||
|
return await request.post(`${WORKER_URL}/redeem_api/query`, { data: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryResult(request: APIRequestContext, code: unknown) {
|
||||||
|
return await request.post(`${WORKER_URL}/redeem_api/result`, { data: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `${uniqueValue('redeem-user')}@test.example.com`;
|
||||||
|
const createResponse = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { email, password: hashPassword('redeem-user-password') },
|
||||||
|
});
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const user = (await listResponse.json()).results.find(
|
||||||
|
(row: { user_email: string }) => row.user_email === email,
|
||||||
|
);
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
await setUserRole(request, user.id, null);
|
||||||
|
return { id: user.id as number, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setUserRole(request: APIRequestContext, userId: number, role: string | null) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { user_id: userId, role_text: role },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||||
|
await setUserRole(request, userId, null);
|
||||||
|
const response = await request.delete(`${WORKER_URL}/admin/users/${userId}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Redemption code query and guards', () => {
|
||||||
|
test('previews each supported type without consuming it', async ({ request }) => {
|
||||||
|
const roleCode = await createCode(request, 'role', 'case-role');
|
||||||
|
const balanceCode = await createCode(request, 'send_balance', '12');
|
||||||
|
const addressCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||||
|
try {
|
||||||
|
const roleResponse = await queryCode(request, roleCode);
|
||||||
|
expect(roleResponse.ok()).toBe(true);
|
||||||
|
expect(await roleResponse.json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
const balanceResponse = await queryCode(request, balanceCode);
|
||||||
|
expect(balanceResponse.ok()).toBe(true);
|
||||||
|
expect(await balanceResponse.json()).toEqual({
|
||||||
|
redeem_type: 'send_balance', value: '12', status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
const addressResponse = await queryCode(request, addressCode);
|
||||||
|
expect(addressResponse.ok()).toBe(true);
|
||||||
|
expect(await addressResponse.json()).toEqual({
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: 'vip',
|
||||||
|
status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await listCode(request, 'role', roleCode)).redeemed_at).toBeNull();
|
||||||
|
expect((await listCode(request, 'send_balance', balanceCode)).redeemed_at).toBeNull();
|
||||||
|
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed_at).toBeNull();
|
||||||
|
expect((await listCode(request, 'role', roleCode)).redeemed).toBe(false);
|
||||||
|
expect((await listCode(request, 'send_balance', balanceCode)).redeemed).toBe(false);
|
||||||
|
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed).toBe(false);
|
||||||
|
expect((await queryResult(request, addressCode)).status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', roleCode);
|
||||||
|
await deleteCode(request, 'send_balance', balanceCode);
|
||||||
|
await deleteCode(request, 'address_prefix_once', addressCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing, malformed, unknown, and disabled codes, but reports expired codes', async ({ request }) => {
|
||||||
|
const disabledCode = await createCode(request, 'role', 'case-role', { enabled: false });
|
||||||
|
const expiredCode = await createCode(request, 'role', 'case-role', {
|
||||||
|
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||||
|
for (const code of [undefined, null, '', 'x'.repeat(257), uniqueValue('missing')]) {
|
||||||
|
expect((await queryCode(request, code)).status()).toBe(400);
|
||||||
|
}
|
||||||
|
expect((await queryCode(request, disabledCode)).status()).toBe(400);
|
||||||
|
const expiredResponse = await queryCode(request, expiredCode);
|
||||||
|
expect(expiredResponse.ok()).toBe(true);
|
||||||
|
expect(await expiredResponse.json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'expired',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', disabledCode);
|
||||||
|
await deleteCode(request, 'role', expiredCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed JSON request bodies', async ({ request }) => {
|
||||||
|
const options = {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
data: '{',
|
||||||
|
};
|
||||||
|
for (const path of ['/query', '/result', '/redeem']) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api${path}`, options);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the code type and does not consume when its required target is missing', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'send_balance', '9');
|
||||||
|
try {
|
||||||
|
const mismatchResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: 'nobody@test.example.com' },
|
||||||
|
});
|
||||||
|
expect(mismatchResponse.status()).toBe(400);
|
||||||
|
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||||
|
expect((await listCode(request, 'send_balance', code)).redeemed_at).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Role redemption', () => {
|
||||||
|
test('concurrent different roles consume only the winning code', async ({ request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const roles = ['case-role', 'empty-role'];
|
||||||
|
const codes = await Promise.all(roles.map((role) => createCode(request, 'role', role)));
|
||||||
|
try {
|
||||||
|
const responses = await Promise.all(codes.map((code) => (
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
})
|
||||||
|
)));
|
||||||
|
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||||
|
expect(responses.filter((response) => response.status() === 409)).toHaveLength(1);
|
||||||
|
const winner = responses.findIndex((response) => response.ok());
|
||||||
|
const loser = 1 - winner;
|
||||||
|
const rows = await Promise.all(codes.map((code) => listCode(request, 'role', code)));
|
||||||
|
expect(rows[winner].redeemed).toBe(true);
|
||||||
|
expect(rows[loser]).toMatchObject({ redeemed: false, result: null, redeemed_at: null });
|
||||||
|
const users = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect((await users.json()).results[0].role_text).toBe(roles[winner]);
|
||||||
|
} finally {
|
||||||
|
for (const code of codes) await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies a configured role by case-insensitive account email and consumes once', async ({ request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const code = await createCode(request, 'role', 'case-role');
|
||||||
|
try {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email.toUpperCase() },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
success: true,
|
||||||
|
type: 'role',
|
||||||
|
role: 'case-role',
|
||||||
|
user_email: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect((await userResponse.json()).results[0].role_text).toBe('case-role');
|
||||||
|
|
||||||
|
const row = await listCode(request, 'role', code);
|
||||||
|
expect(row.redeemed).toBe(true);
|
||||||
|
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||||
|
expect(JSON.parse(row.result)).toMatchObject({
|
||||||
|
type: 'role',
|
||||||
|
user_id: user.id,
|
||||||
|
user_email: user.email,
|
||||||
|
role: 'case-role',
|
||||||
|
});
|
||||||
|
const publicResult = await queryResult(request, code);
|
||||||
|
expect(publicResult.ok()).toBe(true);
|
||||||
|
expect(await publicResult.json()).toEqual({
|
||||||
|
type: 'role', role: 'case-role', user_email: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(repeatedResponse.status()).toBe(400);
|
||||||
|
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'redeemed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts the same existing role', async ({ request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const code = await createCode(request, 'role', 'case-role');
|
||||||
|
try {
|
||||||
|
await setUserRole(request, user.id, 'case-role');
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the code unused when the account is missing or has a conflicting role', async ({ request }) => {
|
||||||
|
const missingCode = await createCode(request, 'role', 'case-role');
|
||||||
|
const conflictCode = await createCode(request, 'role', 'case-role');
|
||||||
|
const user = await createUser(request);
|
||||||
|
try {
|
||||||
|
const missingResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: missingCode, user_email: `${uniqueValue('missing')}@test.example.com` },
|
||||||
|
});
|
||||||
|
expect(missingResponse.status()).toBe(400);
|
||||||
|
expect((await queryCode(request, missingCode)).ok()).toBe(true);
|
||||||
|
|
||||||
|
await setUserRole(request, user.id, 'empty-role');
|
||||||
|
const conflictResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: conflictCode, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(conflictResponse.status()).toBe(409);
|
||||||
|
expect((await queryCode(request, conflictCode)).ok()).toBe(true);
|
||||||
|
expect((await listCode(request, 'role', conflictCode)).result).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', missingCode);
|
||||||
|
await deleteCode(request, 'role', conflictCode);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Sending-credit redemption', () => {
|
||||||
|
test('preserves default credits before the mailbox is first opened', async ({ request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rcfresh');
|
||||||
|
const code = await createCode(request, 'send_balance', '7');
|
||||||
|
try {
|
||||||
|
const before = await request.get(
|
||||||
|
`${WORKER_URL}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address.address)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect((await before.json()).results).toHaveLength(0);
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
expect(await response.json()).toMatchObject({ amount: 7, balance: 17 });
|
||||||
|
for (let index = 0; index < 2; index++) {
|
||||||
|
const settings = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||||
|
});
|
||||||
|
expect(settings.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(17);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesces NULL balance, preserves disabled state, records result, and consumes once', async ({ request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rcnull');
|
||||||
|
const code = await createCode(request, 'send_balance', '7');
|
||||||
|
try {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||||
|
});
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
const sender = await getAddressSender(request, address.address);
|
||||||
|
const nullResponse = await request.post(`${WORKER_URL}/admin/address_sender`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
address: address.address,
|
||||||
|
address_id: sender.id,
|
||||||
|
balance: null,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(nullResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address.toUpperCase() },
|
||||||
|
});
|
||||||
|
expect(redeemResponse.ok()).toBe(true);
|
||||||
|
expect(await redeemResponse.json()).toEqual({
|
||||||
|
success: true,
|
||||||
|
type: 'send_balance',
|
||||||
|
address: address.address,
|
||||||
|
amount: 7,
|
||||||
|
balance: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedSender = await getAddressSender(request, address.address);
|
||||||
|
expect(updatedSender.balance).toBe(7);
|
||||||
|
expect(updatedSender.enabled).toBe(0);
|
||||||
|
expect(JSON.parse((await listCode(request, 'send_balance', code)).result)).toMatchObject({
|
||||||
|
type: 'send_balance',
|
||||||
|
address: address.address,
|
||||||
|
amount: 7,
|
||||||
|
});
|
||||||
|
const publicResult = await queryResult(request, code);
|
||||||
|
expect(publicResult.ok()).toBe(true);
|
||||||
|
expect(await publicResult.json()).toEqual({
|
||||||
|
type: 'send_balance', address: address.address, amount: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
});
|
||||||
|
expect(repeatedResponse.status()).toBe(400);
|
||||||
|
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||||
|
redeem_type: 'send_balance', value: '7', status: 'redeemed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not consume when the target address is missing', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'send_balance', '5');
|
||||||
|
try {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: `${uniqueValue('missing')}@test.example.com` },
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||||
|
expect((await listCode(request, 'send_balance', code)).result).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent requests add the balance exactly once', async ({ request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rcrace');
|
||||||
|
const code = await createCode(request, 'send_balance', '5');
|
||||||
|
try {
|
||||||
|
await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||||
|
});
|
||||||
|
const before = await getAddressSender(request, address.address);
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
}),
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||||
|
expect(responses.filter((response) => [400, 409].includes(response.status()))).toHaveLength(1);
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(before.balance + 5);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Special-address redemption', () => {
|
||||||
|
test('uses the redemption prefix without the system prefix and repeats the complete original result', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'vip');
|
||||||
|
const name = `a${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let result: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name, domain: 'TEST.EXAMPLE.COM' },
|
||||||
|
});
|
||||||
|
expect(firstResponse.ok()).toBe(true);
|
||||||
|
result = await firstResponse.json();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
address: `vip${name}@test.example.com`,
|
||||||
|
address_id: expect.any(Number),
|
||||||
|
jwt: expect.any(String),
|
||||||
|
password: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(result.address).not.toContain('tmpvip');
|
||||||
|
|
||||||
|
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'ignored', domain: 'manual.example.com' },
|
||||||
|
});
|
||||||
|
expect(repeatedResponse.ok()).toBe(true);
|
||||||
|
expect(await repeatedResponse.json()).toEqual(result);
|
||||||
|
|
||||||
|
const previewResponse = await queryCode(request, code);
|
||||||
|
expect(previewResponse.ok()).toBe(true);
|
||||||
|
const preview = await previewResponse.json();
|
||||||
|
expect(preview).toEqual({
|
||||||
|
redeem_type: 'address_prefix_once', value: 'vip', status: 'redeemed',
|
||||||
|
});
|
||||||
|
const publicResult = await queryResult(request, code);
|
||||||
|
expect(publicResult.ok()).toBe(true);
|
||||||
|
expect(await publicResult.json()).toEqual({
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
address: result.address,
|
||||||
|
jwt: result.jwt,
|
||||||
|
password: result.password,
|
||||||
|
});
|
||||||
|
const row = await listCode(request, 'address_prefix_once', code);
|
||||||
|
expect(JSON.parse(row.result)).toEqual(result);
|
||||||
|
} finally {
|
||||||
|
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports an empty prefix and the longest usable prefix', async ({ request }) => {
|
||||||
|
const emptyCode = await createCode(request, 'address_prefix_once', '');
|
||||||
|
const maxCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||||
|
const emptyName = `n${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let emptyResult: AddressRedeemResult | undefined;
|
||||||
|
let maxResult: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const emptyResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: emptyCode, name: emptyName, domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(emptyResponse.ok()).toBe(true);
|
||||||
|
emptyResult = await emptyResponse.json();
|
||||||
|
expect(emptyResult.address).toBe(`${emptyName}@test.example.com`);
|
||||||
|
|
||||||
|
const maxResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: maxCode, name: 'z', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(maxResponse.ok()).toBe(true);
|
||||||
|
maxResult = await maxResponse.json();
|
||||||
|
expect(maxResult.address.split('@')[0]).toBe(`${'a'.repeat(29)}z`);
|
||||||
|
} finally {
|
||||||
|
if (emptyResult?.jwt) await deleteAddress(request, emptyResult.jwt);
|
||||||
|
if (maxResult?.jwt) await deleteAddress(request, maxResult.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', emptyCode);
|
||||||
|
await deleteCode(request, 'address_prefix_once', maxCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps length and domain validation without consuming failed codes', async ({ request }) => {
|
||||||
|
const lengthCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||||
|
const domainCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||||
|
try {
|
||||||
|
const lengthResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: lengthCode, name: 'zz', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(lengthResponse.status()).toBe(400);
|
||||||
|
|
||||||
|
const domainResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: domainCode, name: 'valid', domain: 'invalid.example.com' },
|
||||||
|
});
|
||||||
|
expect(domainResponse.status()).toBe(400);
|
||||||
|
|
||||||
|
expect((await queryCode(request, lengthCode)).ok()).toBe(true);
|
||||||
|
expect((await queryCode(request, domainCode)).ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'address_prefix_once', lengthCode);
|
||||||
|
await deleteCode(request, 'address_prefix_once', domainCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates a valid random suffix when the user omits the name', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'rnd');
|
||||||
|
let result: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
result = await response.json();
|
||||||
|
const localPart = result.address.split('@')[0];
|
||||||
|
expect(localPart.startsWith('rnd')).toBe(true);
|
||||||
|
expect(localPart.length).toBeLessThanOrEqual(30);
|
||||||
|
expect(localPart.length).toBeGreaterThan(3);
|
||||||
|
} finally {
|
||||||
|
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not return an address result after its original address is removed', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'removed');
|
||||||
|
try {
|
||||||
|
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
const first = await firstResponse.json();
|
||||||
|
await deleteAddress(request, first.jwt);
|
||||||
|
|
||||||
|
const repeatResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'ignored', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(repeatResponse.status()).toBe(400);
|
||||||
|
const resultResponse = await request.post(`${WORKER_URL}/redeem_api/result`, {
|
||||||
|
data: { code },
|
||||||
|
});
|
||||||
|
expect(resultResponse.status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent creations converge on one complete result', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'race');
|
||||||
|
let result: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: `a${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||||
|
}),
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: `b${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(responses.every((response) => response.ok())).toBe(true);
|
||||||
|
const results = await Promise.all(responses.map((response) => response.json()));
|
||||||
|
expect(results[1]).toEqual(results[0]);
|
||||||
|
result = results[0];
|
||||||
|
expect(result.password).toEqual(expect.any(String));
|
||||||
|
expect(JSON.parse((await listCode(request, 'address_prefix_once', code)).result)).toEqual(result);
|
||||||
|
} finally {
|
||||||
|
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiry stops retrieval of an existing result', async ({ request }) => {
|
||||||
|
const expiredCode = await createCode(request, 'address_prefix_once', 'exp', {
|
||||||
|
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||||
|
});
|
||||||
|
let expiredResult: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
expiredResult = await (await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: expiredCode, name: 'mail', domain: 'test.example.com' },
|
||||||
|
})).json();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||||
|
|
||||||
|
expect((await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: expiredCode },
|
||||||
|
})).status()).toBe(400);
|
||||||
|
expect(await (await queryCode(request, expiredCode)).json()).toMatchObject({ status: 'expired' });
|
||||||
|
expect((await queryResult(request, expiredCode)).status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
if (expiredResult?.jwt) await deleteAddress(request, expiredResult.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', expiredCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect, APIRequestContext } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
WORKER_URL,
|
WORKER_URL,
|
||||||
WORKER_URL_SEND_MAIL_DOMAIN,
|
WORKER_URL_SEND_MAIL_DOMAIN,
|
||||||
@@ -425,11 +426,11 @@ test.describe('Send Mail Limit', () => {
|
|||||||
expect(res.status()).toBe(400);
|
expect(res.status()).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/admin/send_mail_by_binding returns 200 when domain is allowed', async ({ request }) => {
|
test('/admin/send_mail_by_binding normalizes uppercase allowed domain', async ({ request }) => {
|
||||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||||
headers: ADMIN_HEADERS,
|
headers: ADMIN_HEADERS,
|
||||||
data: {
|
data: {
|
||||||
from: 'admin@test.example.com',
|
from: 'admin@TEST.EXAMPLE.COM',
|
||||||
to: ['recipient@test.example.com'],
|
to: ['recipient@test.example.com'],
|
||||||
subject: `send-mail-domain-ok-${Date.now()}`,
|
subject: `send-mail-domain-ok-${Date.now()}`,
|
||||||
text: 'body',
|
text: 'body',
|
||||||
@@ -439,6 +440,20 @@ test.describe('Send Mail Limit', () => {
|
|||||||
expect(await res.json()).toEqual({ status: 'ok' });
|
expect(await res.json()).toEqual({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('/admin/send_mail_by_binding normalizes object-form from domain', async ({ request }) => {
|
||||||
|
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
from: { email: 'admin@TEST.EXAMPLE.COM', name: 'Admin' },
|
||||||
|
to: ['recipient@test.example.com'],
|
||||||
|
subject: `send-mail-domain-object-ok-${Date.now()}`,
|
||||||
|
text: 'body',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
expect(await res.json()).toEqual({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => {
|
test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => {
|
||||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||||
headers: ADMIN_HEADERS,
|
headers: ADMIN_HEADERS,
|
||||||
|
|||||||
@@ -51,4 +51,33 @@ test.describe('Send Mail via SMTP', () => {
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
await deleteAddress(request, jwt);
|
await deleteAddress(request, jwt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('external sending stores mail fields without bearer credentials', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'external-sender');
|
||||||
|
const mail = {
|
||||||
|
from_name: 'External sender', to_name: 'Recipient', to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `External ${Date.now()}`, content: 'External message', is_html: false,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const listener = onMailpitMessage(message => message.Subject === mail.subject);
|
||||||
|
await listener.ready;
|
||||||
|
const response = await request.post(`${WORKER_URL}/external/api/send_mail`, {
|
||||||
|
data: { ...mail, token: jwt, extra_credential: 'must-not-be-stored' },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
expect((await listener.message).From.Address).toBe(address);
|
||||||
|
const sendbox = await request.get(`${WORKER_URL}/api/sendbox?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(sendbox.ok()).toBe(true);
|
||||||
|
const { results } = await sendbox.json();
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
const stored = JSON.parse(results[0].raw);
|
||||||
|
expect(stored).toMatchObject(mail);
|
||||||
|
expect(stored).not.toHaveProperty('token');
|
||||||
|
expect(stored).not.toHaveProperty('extra_credential');
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
const MANUAL_BASE_DOMAIN = 'manual.example.com';
|
||||||
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
|
||||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
|
||||||
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
|
||||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
|
||||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_DOMAIN}`;
|
||||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
|
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${MANUAL_BASE_DOMAIN}`;
|
||||||
|
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${MANUAL_BASE_DOMAIN}`;
|
||||||
|
const RANDOM_SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||||
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||||
let originalCreateAddressStoredEnabled: boolean | undefined;
|
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||||
let originalEnvOffStoredEnabled: boolean | undefined;
|
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||||
@@ -119,16 +121,16 @@ test.describe('Create Address Subdomain Match', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('persisted false still keeps exact match only', async ({ request }) => {
|
test('random subdomain scope allows a manually entered subdomain', async ({ request }) => {
|
||||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||||
|
|
||||||
const uniqueName = `subdomain-default-${Date.now()}`;
|
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||||
data: { name: uniqueName, domain: SUBDOMAIN },
|
data: { name: uniqueName, domain: RANDOM_SUBDOMAIN },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok()).toBe(false);
|
expect(res.ok()).toBe(true);
|
||||||
expect(await res.text()).toContain('Invalid domain');
|
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||||
@@ -184,13 +186,43 @@ test.describe('Create Address Subdomain Match', () => {
|
|||||||
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleted random subdomain address can be recreated manually', async ({ request }) => {
|
||||||
|
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||||
|
|
||||||
|
const name = `subreuse${Date.now()}`;
|
||||||
|
const firstCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||||
|
data: { name, domain: TEST_DOMAIN, enableRandomSubdomain: true },
|
||||||
|
});
|
||||||
|
expect(firstCreate.ok()).toBe(true);
|
||||||
|
const firstAddress = await firstCreate.json();
|
||||||
|
const generatedDomain = firstAddress.address.split('@')[1];
|
||||||
|
expect(generatedDomain).not.toBe(TEST_DOMAIN);
|
||||||
|
|
||||||
|
const firstDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${firstAddress.jwt}` },
|
||||||
|
});
|
||||||
|
expect(firstDelete.ok()).toBe(true);
|
||||||
|
|
||||||
|
const secondCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||||
|
data: { name, domain: generatedDomain },
|
||||||
|
});
|
||||||
|
expect(secondCreate.ok()).toBe(true);
|
||||||
|
const secondAddress = await secondCreate.json();
|
||||||
|
expect(secondAddress.address).toBe(firstAddress.address);
|
||||||
|
|
||||||
|
const secondDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${secondAddress.jwt}` },
|
||||||
|
});
|
||||||
|
expect(secondDelete.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
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');
|
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||||
|
|
||||||
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
|
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
|
||||||
});
|
});
|
||||||
expect(res.ok()).toBe(false);
|
expect(res.ok()).toBe(false);
|
||||||
expect(await res.text()).toContain('Invalid domain');
|
expect(await res.text()).toContain('Invalid domain');
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
test.describe('Telegram AI extraction rendering', () => {
|
||||||
|
test('realtime mail stores AI extraction metadata for Telegram rendering', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'tg-ai');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subject = `Telegram AI realtime ${Date.now()}`;
|
||||||
|
const raw = [
|
||||||
|
'From: sender@test.example.com',
|
||||||
|
`To: ${address}`,
|
||||||
|
`Subject: ${subject}`,
|
||||||
|
`Message-ID: <telegram-ai-${Date.now()}@test>`,
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=utf-8',
|
||||||
|
'',
|
||||||
|
'Telegram AI extraction realtime body',
|
||||||
|
].join('\r\n');
|
||||||
|
|
||||||
|
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
|
data: {
|
||||||
|
from: 'sender@test.example.com',
|
||||||
|
to: address,
|
||||||
|
raw,
|
||||||
|
ai_extract_result: {
|
||||||
|
type: 'auth_code',
|
||||||
|
result: '123456',
|
||||||
|
result_text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(receiveRes.ok()).toBe(true);
|
||||||
|
const receiveBody = await receiveRes.json();
|
||||||
|
expect(receiveBody.success).toBe(true);
|
||||||
|
|
||||||
|
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(mailsRes.ok()).toBe(true);
|
||||||
|
const { results } = await mailsRes.json();
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
|
||||||
|
const metadata = JSON.parse(results[0].metadata);
|
||||||
|
expect(metadata.ai_extract).toEqual({
|
||||||
|
type: 'auth_code',
|
||||||
|
result: '123456',
|
||||||
|
result_text: '',
|
||||||
|
});
|
||||||
|
expect(metadata.extracted_at).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
function initData(userId: number) {
|
||||||
|
const fields = {
|
||||||
|
auth_date: String(Math.floor(Date.now() / 1000)),
|
||||||
|
user: JSON.stringify({ id: userId }),
|
||||||
|
};
|
||||||
|
const key = createHmac('sha256', 'WebAppData').update('e2e-telegram-test-token').digest();
|
||||||
|
const hash = createHmac('sha256', key)
|
||||||
|
.update(Object.entries(fields).map(([name, value]) => `${name}=${value}`).join('\n'))
|
||||||
|
.digest('hex');
|
||||||
|
return new URLSearchParams({ ...fields, hash }).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bind(request: APIRequestContext, userId: number, jwt: string) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/telegram/bind_address`, {
|
||||||
|
data: { initData: initData(userId), jwt },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind(request: APIRequestContext, userId: number, address: string, status = 200) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/telegram/unbind_address`, {
|
||||||
|
data: { initData: initData(userId), address },
|
||||||
|
});
|
||||||
|
expect(response.status(), await response.text()).toBe(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addressList(request: APIRequestContext, userId: number) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
|
||||||
|
data: { initData: initData(userId) },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPushOwner(request: APIRequestContext, address: string, userId: number | null) {
|
||||||
|
const response = await request.get(`${WORKER_URL}/__test/telegram_binding`, {
|
||||||
|
params: { address },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
expect(await response.json()).toBe(userId === null ? null : String(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Telegram users can remove their own bindings after another user binds the mailbox', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'tg-owner');
|
||||||
|
const owner = Date.now();
|
||||||
|
const other = owner + 1;
|
||||||
|
try {
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
await unbind(request, other, mailbox.address, 400);
|
||||||
|
await expectPushOwner(request, mailbox.address, owner);
|
||||||
|
await unbind(request, owner, mailbox.address);
|
||||||
|
await expectPushOwner(request, mailbox.address, null);
|
||||||
|
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
await bind(request, other, mailbox.jwt);
|
||||||
|
await expectPushOwner(request, mailbox.address, other);
|
||||||
|
await unbind(request, owner, mailbox.address);
|
||||||
|
expect(await addressList(request, owner)).toEqual([]);
|
||||||
|
expect(await addressList(request, other)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
|
||||||
|
await expectPushOwner(request, mailbox.address, other);
|
||||||
|
await unbind(request, other, mailbox.address);
|
||||||
|
expect(await addressList(request, other)).toEqual([]);
|
||||||
|
await expectPushOwner(request, mailbox.address, null);
|
||||||
|
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
await bind(request, other, mailbox.jwt);
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
expect(await addressList(request, owner)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
|
||||||
|
await expectPushOwner(request, mailbox.address, owner);
|
||||||
|
await unbind(request, other, mailbox.address);
|
||||||
|
await expectPushOwner(request, mailbox.address, owner);
|
||||||
|
await unbind(request, owner, mailbox.address);
|
||||||
|
expect(await addressList(request, owner)).toEqual([]);
|
||||||
|
await expectPushOwner(request, mailbox.address, null);
|
||||||
|
} finally {
|
||||||
|
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${mailbox.jwt}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale Telegram credentials cannot unbind; internal mailbox cleanup still works', async ({ request }) => {
|
||||||
|
const original = await createTestAddress(request, 'tg-stale');
|
||||||
|
const owner = Date.now();
|
||||||
|
await bind(request, owner, original.jwt);
|
||||||
|
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
|
||||||
|
expect(deletion.ok()).toBe(true);
|
||||||
|
const [name, domain] = original.address.split('@');
|
||||||
|
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name, domain, enablePrefix: false },
|
||||||
|
});
|
||||||
|
expect(creation.ok()).toBe(true);
|
||||||
|
const recreated = await creation.json();
|
||||||
|
try {
|
||||||
|
expect(recreated.address_id).not.toBe(original.address_id);
|
||||||
|
await unbind(request, owner, recreated.address, 400);
|
||||||
|
await expectPushOwner(request, recreated.address, owner);
|
||||||
|
const response = await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${recreated.jwt}` },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
await expectPushOwner(request, recreated.address, null);
|
||||||
|
|
||||||
|
const listing = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
|
||||||
|
data: { initData: initData(owner) },
|
||||||
|
});
|
||||||
|
expect(listing.ok()).toBe(true);
|
||||||
|
expect(await listing.json()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await request.delete(`${WORKER_URL}/admin/delete_address/${recreated.address_id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Telegram unbind accepts a current credential after a stale credential for the same address', async ({ request }) => {
|
||||||
|
const original = await createTestAddress(request, 'tg-recreated');
|
||||||
|
const unrelated = await createTestAddress(request, 'tg-retained');
|
||||||
|
const owner = Date.now();
|
||||||
|
await bind(request, owner, original.jwt);
|
||||||
|
await bind(request, owner, unrelated.jwt);
|
||||||
|
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
|
||||||
|
expect(deletion.ok()).toBe(true);
|
||||||
|
const [name, domain] = original.address.split('@');
|
||||||
|
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name, domain, enablePrefix: false },
|
||||||
|
});
|
||||||
|
expect(creation.ok()).toBe(true);
|
||||||
|
const recreated = await creation.json();
|
||||||
|
try {
|
||||||
|
expect(recreated.address_id).not.toBe(original.address_id);
|
||||||
|
await bind(request, owner, recreated.jwt);
|
||||||
|
await unbind(request, owner, recreated.address);
|
||||||
|
await expectPushOwner(request, recreated.address, null);
|
||||||
|
expect(await addressList(request, owner)).toEqual([{ address: unrelated.address, jwt: unrelated.jwt }]);
|
||||||
|
await expectPushOwner(request, unrelated.address, owner);
|
||||||
|
} finally {
|
||||||
|
for (const mailbox of [recreated, unrelated]) {
|
||||||
|
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${mailbox.jwt}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { WORKER_URL, WORKER_URL_SITE_PASSWORD } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
for (const scenario of ['expired role', 'valid role', 'invalid signature', 'missing expiry', 'admin password', 'wrong password'] as const) {
|
||||||
|
test(`Admin role token errors remain distinct from password errors: ${scenario}`, async ({ request }) => {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
user_id: 1, user_role: 'admin',
|
||||||
|
exp: scenario === 'missing expiry' ? undefined
|
||||||
|
: Math.floor(Date.now() / 1000) + (scenario === 'valid role' || scenario === 'invalid signature' ? 3600 : -60),
|
||||||
|
})).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', scenario === 'invalid signature' ? 'wrong-secret' : 'e2e-site-password-secret')
|
||||||
|
.update(`${header}.${payload}`).digest('base64url');
|
||||||
|
const headers: Record<string, string> = { 'x-custom-auth': 'e2e-site-pass', 'x-lang': 'en' };
|
||||||
|
if (scenario !== 'wrong password') headers['x-user-access-token'] = `${header}.${payload}.${signature}`;
|
||||||
|
if (scenario === 'admin password') headers['x-admin-auth'] = 'e2e-admin-pass';
|
||||||
|
if (scenario === 'wrong password') headers['x-admin-auth'] = 'wrong-password';
|
||||||
|
const response = await request.get(`${WORKER_URL_SITE_PASSWORD}/admin/db_version`, { headers });
|
||||||
|
if (scenario === 'expired role') {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||||
|
message: 'Your access token has expired, please refresh the page',
|
||||||
|
});
|
||||||
|
} else if (scenario === 'missing expiry') {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.text()).toBe('Your access token has expired, please refresh the page');
|
||||||
|
} else if (scenario === 'valid role' || scenario === 'admin password') {
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.text()).toBe('You need to provide the admin password to access this page');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generic authentication errors preserve their text messages and CORS headers', async ({ request }) => {
|
||||||
|
for (const { path, message } of [
|
||||||
|
{ path: '/api/settings', message: 'Invalid address credential' },
|
||||||
|
{ path: '/user_api/settings', message: 'Your token has expired, please login again' },
|
||||||
|
]) {
|
||||||
|
const response = await request.get(`${WORKER_URL}${path}`, { headers: { 'x-lang': 'en' } });
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(response.headers()['content-type']).toContain('text/plain');
|
||||||
|
expect(response.headers()['access-control-allow-origin']).toBe('*');
|
||||||
|
expect(await response.text()).toBe(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uncaught server errors return JSON with the original error detail', async ({ request }) => {
|
||||||
|
const response = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
data: Buffer.from('{invalid-json'),
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(500);
|
||||||
|
expect(response.headers()['content-type']).toContain('application/json');
|
||||||
|
expect(await response.json()).toEqual({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('SyntaxError') });
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
hashPassword,
|
||||||
|
seedTestMail,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `address-page-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt } = await loginRes.json();
|
||||||
|
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
return { jwt, userId: payload.user_id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('User address pagination', () => {
|
||||||
|
test('paginates addresses and enforces mail ownership', async ({ request }) => {
|
||||||
|
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let outsider: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
let userId: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsRes.json();
|
||||||
|
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
const userJwt = user.jwt;
|
||||||
|
userId = user.userId;
|
||||||
|
addresses.push(...await Promise.all([
|
||||||
|
createTestAddress(request, 'user-page-a'),
|
||||||
|
createTestAddress(request, 'user-page-b'),
|
||||||
|
createTestAddress(request, 'user-page-c'),
|
||||||
|
]));
|
||||||
|
outsider = await createTestAddress(request, 'user-page-outsider');
|
||||||
|
|
||||||
|
for (const item of addresses) {
|
||||||
|
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${item.jwt}`,
|
||||||
|
'x-user-token': userJwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bindRes.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPageRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
});
|
||||||
|
expect(defaultPageRes.ok()).toBe(true);
|
||||||
|
const defaultPage = await defaultPageRes.json();
|
||||||
|
expect(defaultPage.count).toBe(3);
|
||||||
|
expect(defaultPage.results).toHaveLength(3);
|
||||||
|
|
||||||
|
const firstPageRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/bind_address?limit=2&offset=0`,
|
||||||
|
{ headers: { 'x-user-token': userJwt } },
|
||||||
|
);
|
||||||
|
expect(firstPageRes.ok()).toBe(true);
|
||||||
|
const firstPage = await firstPageRes.json();
|
||||||
|
expect(firstPage.count).toBe(3);
|
||||||
|
expect(firstPage.results).toHaveLength(2);
|
||||||
|
expect(firstPage.results[0].mail_count).toBe(0);
|
||||||
|
expect(firstPage.results[0].send_count).toBe(0);
|
||||||
|
expect(firstPage.results[0]).toHaveProperty('source_meta');
|
||||||
|
expect(firstPage.results[0]).not.toHaveProperty('password');
|
||||||
|
|
||||||
|
const secondPageRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/bind_address?limit=2&offset=2`,
|
||||||
|
{ headers: { 'x-user-token': userJwt } },
|
||||||
|
);
|
||||||
|
expect(secondPageRes.ok()).toBe(true);
|
||||||
|
const secondPage = await secondPageRes.json();
|
||||||
|
expect(secondPage.count).toBe(0);
|
||||||
|
expect(secondPage.results).toHaveLength(1);
|
||||||
|
|
||||||
|
const invalidLimitRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/bind_address?limit=101&offset=0`,
|
||||||
|
{ headers: { 'x-user-token': userJwt } },
|
||||||
|
);
|
||||||
|
expect(invalidLimitRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const invalidOffsetRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/bind_address?limit=20&offset=-1`,
|
||||||
|
{ headers: { 'x-user-token': userJwt } },
|
||||||
|
);
|
||||||
|
expect(invalidOffsetRes.status()).toBe(400);
|
||||||
|
|
||||||
|
await seedTestMail(request, addresses[0].address, { subject: 'Bound mail' });
|
||||||
|
await seedTestMail(request, outsider.address, { subject: 'Outsider mail' });
|
||||||
|
|
||||||
|
const userMailsRes = await request.get(`${WORKER_URL}/user_api/mails?limit=20&offset=0`, {
|
||||||
|
headers: { 'x-user-token': userJwt },
|
||||||
|
});
|
||||||
|
expect(userMailsRes.ok()).toBe(true);
|
||||||
|
const userMails = await userMailsRes.json();
|
||||||
|
expect(userMails.count).toBe(1);
|
||||||
|
expect(userMails.results[0].address).toBe(addresses[0].address);
|
||||||
|
|
||||||
|
const filteredOutsiderMailsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/mails?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||||
|
{ headers: { 'x-user-token': userJwt } },
|
||||||
|
);
|
||||||
|
expect(filteredOutsiderMailsRes.ok()).toBe(true);
|
||||||
|
const filteredOutsiderMails = await filteredOutsiderMailsRes.json();
|
||||||
|
expect(filteredOutsiderMails.count).toBe(0);
|
||||||
|
expect(filteredOutsiderMails.results).toHaveLength(0);
|
||||||
|
|
||||||
|
const outsiderMailsRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||||
|
});
|
||||||
|
expect(outsiderMailsRes.ok()).toBe(true);
|
||||||
|
const outsiderMails = await outsiderMailsRes.json();
|
||||||
|
expect(outsiderMails.results).toHaveLength(1);
|
||||||
|
|
||||||
|
const forbiddenDeleteRes = await request.delete(
|
||||||
|
`${WORKER_URL}/user_api/mails/${outsiderMails.results[0].id}`,
|
||||||
|
{ headers: { 'x-user-token': userJwt } },
|
||||||
|
);
|
||||||
|
expect(forbiddenDeleteRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const outsiderAfterRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||||
|
});
|
||||||
|
expect(outsiderAfterRes.ok()).toBe(true);
|
||||||
|
const outsiderAfter = await outsiderAfterRes.json();
|
||||||
|
expect(outsiderAfter.results).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[...addresses, outsider].filter((item) => item !== undefined)
|
||||||
|
.map((item) => deleteAddress(request, item.jwt)),
|
||||||
|
);
|
||||||
|
if (userId !== undefined) {
|
||||||
|
const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
expect(deleteUserRes.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalUserSettings) {
|
||||||
|
const restoreSettingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: originalUserSettings,
|
||||||
|
});
|
||||||
|
expect(restoreSettingsRes.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
async function resetUserSettings(request: APIRequestContext) {
|
||||||
|
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
enableMailAllowList: false,
|
||||||
|
mailAllowList: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetOauth2Settings(request: APIRequestContext) {
|
||||||
|
const res = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startOauthServer(email: string): Promise<{ server: http.Server; baseUrl: string }> {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.url === '/token') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ access_token: 'token', token_type: 'Bearer' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === '/userinfo') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ email }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 OAuth test server port');
|
||||||
|
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
|
||||||
|
return { server, baseUrl: `http://${hostname}:${addr.port}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('User domain normalization', () => {
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
await resetOauth2Settings(request);
|
||||||
|
await resetUserSettings(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes uppercase verify mail sender domain in admin settings', async ({ request }) => {
|
||||||
|
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: true,
|
||||||
|
verifyMailSender: 'verify@TEST.EXAMPLE.COM',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes uppercase user registration allow-list domains', async ({ request }) => {
|
||||||
|
const saveRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
enableMailAllowList: true,
|
||||||
|
mailAllowList: ['TEST.EXAMPLE.COM'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(saveRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: {
|
||||||
|
email: `allow-list-${Date.now()}@TEST.EXAMPLE.COM`,
|
||||||
|
password: 'allow-list-password',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes uppercase OAuth2 allow-list domains', async ({ request }) => {
|
||||||
|
const email = `oauth-allow-${Date.now()}@TEST.EXAMPLE.COM`;
|
||||||
|
const { server, baseUrl } = await startOauthServer(email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveRes = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
|
||||||
|
data: [{
|
||||||
|
name: 'case-oauth',
|
||||||
|
clientID: 'case-client',
|
||||||
|
clientSecret: 'case-secret',
|
||||||
|
authorizationURL: `${baseUrl}/authorize`,
|
||||||
|
accessTokenURL: `${baseUrl}/token`,
|
||||||
|
accessTokenFormat: 'json',
|
||||||
|
userInfoURL: `${baseUrl}/userinfo`,
|
||||||
|
redirectURL: `${baseUrl}/callback`,
|
||||||
|
userEmailKey: 'email',
|
||||||
|
scope: 'openid email',
|
||||||
|
enableMailAllowList: true,
|
||||||
|
mailAllowList: ['TEST.EXAMPLE.COM'],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
expect(saveRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const callbackRes = await request.post(`${WORKER_URL}/user_api/oauth2/callback`, {
|
||||||
|
data: { clientID: 'case-client', code: 'case-code' },
|
||||||
|
});
|
||||||
|
expect(callbackRes.ok()).toBe(true);
|
||||||
|
const body = await callbackRes.json();
|
||||||
|
expect(body.jwt).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
deleteAllMailpitMessages,
|
||||||
|
getAddressSender,
|
||||||
|
hashPassword,
|
||||||
|
onMailpitMessage,
|
||||||
|
updateAddressSender,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `user-send-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt } = await loginRes.json();
|
||||||
|
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
return { jwt, userId: payload.user_id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindAddress(
|
||||||
|
request: APIRequestContext,
|
||||||
|
userJwt: string,
|
||||||
|
addressJwt: string,
|
||||||
|
) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${addressJwt}`,
|
||||||
|
'x-user-token': userJwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('User send mail API', () => {
|
||||||
|
test('sends and manages sent items for a bound address only', async ({ request }) => {
|
||||||
|
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let userId: number | undefined;
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsRes.json();
|
||||||
|
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
userId = user.userId;
|
||||||
|
const bound = await createTestAddress(request, 'user-send-bound-');
|
||||||
|
const accessRequest = await createTestAddress(request, 'user-send-access-');
|
||||||
|
const outsider = await createTestAddress(request, 'usr-outsider-');
|
||||||
|
addresses.push(bound, accessRequest, outsider);
|
||||||
|
await bindAddress(request, user.jwt, bound.jwt);
|
||||||
|
await bindAddress(request, user.jwt, accessRequest.jwt);
|
||||||
|
|
||||||
|
const invalidAddressSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/0/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(invalidAddressSettingsRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderSettingsRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderCredentialRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderCredentialRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderAccessRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderUserSendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: 'Forbidden user send',
|
||||||
|
content: 'This message must not be sent',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(outsiderUserSendRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const unauthenticatedSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||||
|
);
|
||||||
|
expect(unauthenticatedSendboxRes.status()).toBe(401);
|
||||||
|
|
||||||
|
const requestAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(requestAccessRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const accessSender = await getAddressSender(request, accessRequest.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: accessRequest.address,
|
||||||
|
address_id: accessSender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
const duplicateAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(duplicateAccessRes.status()).toBe(400);
|
||||||
|
expect(await duplicateAccessRes.text()).toContain('Already');
|
||||||
|
|
||||||
|
const addressSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(addressSettingsRes.ok()).toBe(true);
|
||||||
|
const addressSettings = await addressSettingsRes.json();
|
||||||
|
expect(addressSettings.address).toBe(bound.address);
|
||||||
|
expect(addressSettings.send_balance).toBe(10);
|
||||||
|
|
||||||
|
await deleteAllMailpitMessages(request);
|
||||||
|
const subject = `User API send ${Date.now()}`;
|
||||||
|
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
|
||||||
|
const sendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
data: {
|
||||||
|
from_name: 'User Sender',
|
||||||
|
from_mail: outsider.address,
|
||||||
|
to_name: 'Recipient',
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject,
|
||||||
|
content: 'Sent through the user API',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(sendRes.ok()).toBe(true);
|
||||||
|
const delivered = await listener.message;
|
||||||
|
expect(delivered.From.Address).toBe(bound.address);
|
||||||
|
|
||||||
|
const outsiderSubject = `Outsider send ${Date.now()}`;
|
||||||
|
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||||
|
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: outsiderSubject,
|
||||||
|
content: 'This sent item must remain inaccessible to the user',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(outsiderSendRes.ok()).toBe(true);
|
||||||
|
const outsiderAddressSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||||
|
);
|
||||||
|
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
|
||||||
|
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
|
||||||
|
JSON.parse(item.raw).subject === outsiderSubject
|
||||||
|
));
|
||||||
|
expect(outsiderMail).toBeTruthy();
|
||||||
|
|
||||||
|
const unauthorizedDeleteRes = await request.delete(
|
||||||
|
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(unauthorizedDeleteRes.ok()).toBe(true);
|
||||||
|
const outsiderSendboxAfterDeleteRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||||
|
);
|
||||||
|
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
|
||||||
|
|
||||||
|
const updatedSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
|
||||||
|
|
||||||
|
const sender = await getAddressSender(request, bound.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: bound.address,
|
||||||
|
address_id: sender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
const noBalanceRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: 'No balance user send',
|
||||||
|
content: 'This message must not be sent',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(noBalanceRes.status()).toBe(400);
|
||||||
|
expect(await noBalanceRes.text()).toContain('No balance');
|
||||||
|
|
||||||
|
const userSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(userSendboxRes.ok()).toBe(true);
|
||||||
|
const userSendbox = await userSendboxRes.json();
|
||||||
|
expect(userSendbox.count).toBe(1);
|
||||||
|
expect(userSendbox.results).toHaveLength(1);
|
||||||
|
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
|
||||||
|
|
||||||
|
const filteredSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(filteredSendboxRes.ok()).toBe(true);
|
||||||
|
expect((await filteredSendboxRes.json()).count).toBe(1);
|
||||||
|
|
||||||
|
const outsiderFilterRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderFilterRes.ok()).toBe(true);
|
||||||
|
expect((await outsiderFilterRes.json()).count).toBe(0);
|
||||||
|
|
||||||
|
const deleteRes = await request.delete(
|
||||||
|
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(deleteRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const emptySendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
const emptySendbox = await emptySendboxRes.json();
|
||||||
|
expect(emptySendbox.count).toBe(0);
|
||||||
|
expect(emptySendbox.results).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||||
|
if (userId !== undefined) {
|
||||||
|
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalUserSettings) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: originalUserSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies unlimited balance from the user role access token', async ({ request }) => {
|
||||||
|
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
const userIds: number[] = [];
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsRes.json();
|
||||||
|
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
userIds.push(user.userId);
|
||||||
|
const address = await createTestAddress(request, 'user-send-role-');
|
||||||
|
addresses.push(address);
|
||||||
|
await bindAddress(request, user.jwt, address.jwt);
|
||||||
|
|
||||||
|
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
data: { user_id: user.userId, role_text: 'case-role' },
|
||||||
|
});
|
||||||
|
expect(updateRoleRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const accessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(accessRes.ok()).toBe(true);
|
||||||
|
const sender = await getAddressSender(request, address.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: address.address,
|
||||||
|
address_id: sender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
});
|
||||||
|
expect(userSettingsRes.ok()).toBe(true);
|
||||||
|
const { access_token: accessToken } = await userSettingsRes.json();
|
||||||
|
expect(accessToken).toBeTruthy();
|
||||||
|
const userHeaders = {
|
||||||
|
'x-user-token': user.jwt,
|
||||||
|
'x-user-access-token': accessToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addressSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
|
||||||
|
{ headers: userHeaders },
|
||||||
|
);
|
||||||
|
expect(addressSettingsRes.ok()).toBe(true);
|
||||||
|
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
|
||||||
|
|
||||||
|
const sendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: userHeaders,
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `Unlimited role send ${Date.now()}`,
|
||||||
|
content: 'Sent without consuming address balance',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(sendRes.ok()).toBe(true);
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(0);
|
||||||
|
|
||||||
|
const otherUser = await createUser(request);
|
||||||
|
userIds.push(otherUser.userId);
|
||||||
|
const otherAddress = await createTestAddress(request, 'user-send-other-');
|
||||||
|
addresses.push(otherAddress);
|
||||||
|
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
|
||||||
|
const otherAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': otherUser.jwt } },
|
||||||
|
);
|
||||||
|
expect(otherAccessRes.ok()).toBe(true);
|
||||||
|
const otherSender = await getAddressSender(request, otherAddress.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: otherAddress.address,
|
||||||
|
address_id: otherSender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixedHeaders = {
|
||||||
|
'x-user-token': otherUser.jwt,
|
||||||
|
'x-user-access-token': accessToken,
|
||||||
|
};
|
||||||
|
const otherSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
|
||||||
|
{ headers: mixedHeaders },
|
||||||
|
);
|
||||||
|
expect(otherSettingsRes.ok()).toBe(true);
|
||||||
|
expect((await otherSettingsRes.json()).send_balance).toBe(0);
|
||||||
|
|
||||||
|
const otherSendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: mixedHeaders,
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `Mismatched role token ${Date.now()}`,
|
||||||
|
content: 'This message must not be sent',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(otherSendRes.status()).toBe(400);
|
||||||
|
expect(await otherSendRes.text()).toContain('No balance');
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||||
|
await Promise.allSettled(userIds.map((userId) => (
|
||||||
|
request.delete(`${WORKER_URL}/admin/users/${userId}`)
|
||||||
|
)));
|
||||||
|
if (originalUserSettings) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: originalUserSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -72,6 +72,9 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
|||||||
from: '${from}',
|
from: '${from}',
|
||||||
to: '${to}',
|
to: '${to}',
|
||||||
subject: '${subject}',
|
subject: '${subject}',
|
||||||
|
aiExtractType: '${aiExtractType}',
|
||||||
|
aiExtractResult: '${aiExtractResult}',
|
||||||
|
aiExtractResultText: '${aiExtractResultText}',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -92,8 +95,17 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
|||||||
`Webhook trigger test body`,
|
`Webhook trigger test body`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: {
|
||||||
|
from,
|
||||||
|
to: address,
|
||||||
|
raw,
|
||||||
|
ai_extract_result: {
|
||||||
|
type: 'auth_code',
|
||||||
|
result: '654321',
|
||||||
|
result_text: 'Login verification code',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(res.ok()).toBe(true);
|
expect(res.ok()).toBe(true);
|
||||||
|
|
||||||
@@ -106,6 +118,9 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
|||||||
expect(payload.from).toContain('webhook-sender@test.example.com');
|
expect(payload.from).toContain('webhook-sender@test.example.com');
|
||||||
expect(payload.to).toBe(address);
|
expect(payload.to).toBe(address);
|
||||||
expect(payload.subject).toBe(subject);
|
expect(payload.subject).toBe(subject);
|
||||||
|
expect(payload.aiExtractType).toBe('auth_code');
|
||||||
|
expect(payload.aiExtractResult).toBe('654321');
|
||||||
|
expect(payload.aiExtractResultText).toBe('Login verification code');
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
}
|
}
|
||||||
@@ -142,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
|||||||
`Should not trigger webhook`,
|
`Should not trigger webhook`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from: 'sender@test.example.com', to: address, raw },
|
data: { from: 'sender@test.example.com', to: address, raw },
|
||||||
});
|
});
|
||||||
expect(res.ok()).toBe(true);
|
expect(res.ok()).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
test('waits for access settings before showing the Admin password dialog', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const testWindow = window as Window & { __adminPasswordRendered?: boolean };
|
||||||
|
testWindow.__adminPasswordRendered = false;
|
||||||
|
|
||||||
|
const detectAdminPassword = () => {
|
||||||
|
const textNodes = document.createTreeWalker(document, NodeFilter.SHOW_TEXT);
|
||||||
|
while (textNodes.nextNode()) {
|
||||||
|
if (textNodes.currentNode.textContent?.trim() === '管理员密码') {
|
||||||
|
testWindow.__adminPasswordRendered = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
new MutationObserver(detectAdminPassword).observe(document, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let releaseSettings!: () => void;
|
||||||
|
const settingsPending = new Promise<void>((resolve) => {
|
||||||
|
releaseSettings = resolve;
|
||||||
|
});
|
||||||
|
let markSettingsRequested!: () => void;
|
||||||
|
const settingsRequested = new Promise<void>((resolve) => {
|
||||||
|
markSettingsRequested = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/open_api/settings', async (route) => {
|
||||||
|
markSettingsRequested();
|
||||||
|
await settingsPending;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/zh/admin`);
|
||||||
|
await settingsRequested;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
releaseSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByText('快速设置', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => (
|
||||||
|
window as Window & { __adminPasswordRendered?: boolean }
|
||||||
|
).__adminPasswordRendered)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { FRONTEND_URL, TEST_DOMAIN, deleteAddress } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
test('create an address with a custom subdomain from the UI', async ({ page, request }) => {
|
||||||
|
let jwt: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await page.getByRole('button', { name: 'Create Email Address' }).click();
|
||||||
|
|
||||||
|
const name = `subui${Date.now()}`;
|
||||||
|
const createForm = page.locator('.n-tab-pane:visible form');
|
||||||
|
await createForm.locator('.n-input-group .n-input input').fill(name);
|
||||||
|
|
||||||
|
const domainSelect = createForm.locator('.n-input-group .n-select');
|
||||||
|
await expect(domainSelect.locator('input')).toHaveCount(0);
|
||||||
|
|
||||||
|
const normalSubdomain = createForm.getByRole('radio', { name: 'Normal Domain' });
|
||||||
|
const randomSubdomain = createForm.getByRole('radio', { name: 'Use Random Subdomain' });
|
||||||
|
const customSubdomain = createForm.getByRole('radio', { name: 'Use Custom Subdomain' });
|
||||||
|
|
||||||
|
await expect(normalSubdomain).toBeChecked();
|
||||||
|
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||||
|
await expect(normalSubdomain).not.toBeChecked();
|
||||||
|
await expect(randomSubdomain).toBeChecked();
|
||||||
|
await expect(customSubdomain).not.toBeChecked();
|
||||||
|
|
||||||
|
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||||
|
await expect(randomSubdomain).not.toBeChecked();
|
||||||
|
await expect(customSubdomain).toBeChecked();
|
||||||
|
|
||||||
|
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||||
|
await expect(randomSubdomain).toBeChecked();
|
||||||
|
await expect(customSubdomain).not.toBeChecked();
|
||||||
|
|
||||||
|
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||||
|
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
|
||||||
|
|
||||||
|
await createForm.getByRole('button', { name: 'Create Email Address' }).click();
|
||||||
|
|
||||||
|
const domain = `team.${TEST_DOMAIN}`;
|
||||||
|
const address = `tmp${name}@${domain}`;
|
||||||
|
await expect(page.locator('code').getByText(address, { exact: true })).toBeVisible();
|
||||||
|
await page.waitForFunction(() => Boolean(localStorage.getItem('jwt')));
|
||||||
|
jwt = await page.evaluate(() => localStorage.getItem('jwt') || undefined);
|
||||||
|
expect(jwt).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
if (jwt) await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { FRONTEND_URL, WORKER_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
|
||||||
|
test('persists the selected D1 plan and restores it after reload', async ({ page, request }) => {
|
||||||
|
const seedResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { key: 'd1_storage_plan', value: 'free' },
|
||||||
|
});
|
||||||
|
expect(seedResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'qucickSetup');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||||
|
|
||||||
|
const storagePanel = page.locator('.storage-panel');
|
||||||
|
const planSelect = storagePanel.locator('.plan-select .n-select');
|
||||||
|
|
||||||
|
await expect(storagePanel.getByText('Current Database Size', { exact: true })).toBeVisible();
|
||||||
|
await expect(storagePanel.getByText('Database Capacity Limit', { exact: true })).toBeVisible();
|
||||||
|
await expect(storagePanel.getByText('Capacity Usage', { exact: true })).toBeVisible();
|
||||||
|
await expect(planSelect).toContainText('Free');
|
||||||
|
|
||||||
|
await planSelect.click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: 'Workers Paid' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Workers plan saved')).toBeVisible();
|
||||||
|
await expect(storagePanel).toContainText('10.0 GB');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(planSelect).toContainText('Workers Paid');
|
||||||
|
await expect(storagePanel).toContainText('10.0 GB');
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const installLocaleInitScript = async (page: Page, locales: string[], preferredLocale: string | null = null) => {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ locales: initialLocales, preferredLocale: initialPreferredLocale }: { locales: string[]; preferredLocale: string | null }) => {
|
||||||
|
const localeInitStorageKey = '__localeInitDone';
|
||||||
|
if (!window.sessionStorage.getItem(localeInitStorageKey)) {
|
||||||
|
window.localStorage.removeItem('preferredLocale');
|
||||||
|
if (initialPreferredLocale) {
|
||||||
|
window.localStorage.setItem('preferredLocale', initialPreferredLocale);
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(localeInitStorageKey, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window.navigator, 'language', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => initialLocales[0],
|
||||||
|
});
|
||||||
|
Object.defineProperty(window.navigator, 'languages', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => initialLocales,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ locales, preferredLocale },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLanguage = async (page: Page, selectTrigger: Locator, optionLabel: string) => {
|
||||||
|
await selectTrigger.click();
|
||||||
|
const option = page.locator('.n-dropdown-option, .n-dropdown-option-body').filter({ hasText: optionLabel }).first();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Locale switching', () => {
|
||||||
|
test('keeps default route in Chinese while persisting browser language preference', async ({ page }) => {
|
||||||
|
await installLocaleInitScript(page, ['es-ES', 'en-US']);
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/`);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(`${FRONTEND_URL}/`);
|
||||||
|
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('preferredLocale'))).toBe('es');
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('zh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile drawer switch updates locale route and persisted preference', async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 844 });
|
||||||
|
await installLocaleInitScript(page, ['zh-CN']);
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /菜单|Menu/i }).click();
|
||||||
|
|
||||||
|
const drawerLocaleDropdown = page.locator('.n-drawer').getByRole('button', { name: /中文|English|Español|Português|日本語|Deutsch/ }).first();
|
||||||
|
await selectLanguage(page, drawerLocaleDropdown, 'Deutsch');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(`${FRONTEND_URL}/de/`);
|
||||||
|
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('preferredLocale'))).toBe('de');
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('de');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FRONTEND_URL,
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
seedTestMail,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
test('keeps refresh unread and supports automatic and manual state changes', async ({ page }) => {
|
||||||
|
const request = await apiRequest.newContext();
|
||||||
|
let jwt: string | undefined;
|
||||||
|
try {
|
||||||
|
const mailbox = await createTestAddress(request, 'mail-read-browser');
|
||||||
|
jwt = mailbox.jwt;
|
||||||
|
const subject = `Unread browser mail ${Date.now()}`;
|
||||||
|
await seedTestMail(request, mailbox.address, { subject });
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||||
|
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
const mail = (await list.json()).results.find((item: any) => item.raw.includes(subject));
|
||||||
|
expect(mail.is_unread).toBe(1);
|
||||||
|
await expect(page.locator('.mail-list-unread').filter({ hasText: subject })).toBeVisible();
|
||||||
|
|
||||||
|
await page.route('**/api/mails/*/read', async route => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
const waitForReadUpdate = (isUnread: boolean) => page.waitForResponse(response =>
|
||||||
|
/\/api\/mails\/\d+\/read$/.test(new URL(response.url()).pathname)
|
||||||
|
&& response.request().method() === 'PATCH'
|
||||||
|
&& response.request().postDataJSON().isUnread === isUnread
|
||||||
|
);
|
||||||
|
const readResponse = waitForReadUpdate(false);
|
||||||
|
await page.getByText(subject, { exact: true }).click();
|
||||||
|
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
|
||||||
|
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
expect((await readResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
|
||||||
|
const unreadResponse = waitForReadUpdate(true);
|
||||||
|
await page.getByRole('button', { name: 'Mark as Unread' }).click();
|
||||||
|
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(1);
|
||||||
|
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Read' })).toHaveClass(/n-button--loading/);
|
||||||
|
expect((await unreadResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Read' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
|
||||||
|
const manualReadResponse = waitForReadUpdate(false);
|
||||||
|
await page.getByRole('button', { name: 'Mark as Read' }).click();
|
||||||
|
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
|
||||||
|
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toHaveClass(/n-button--loading/);
|
||||||
|
expect((await manualReadResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
|
||||||
|
const updatedList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
const updatedMail = (await updatedList.json()).results.find((item: any) => item.id === mail.id);
|
||||||
|
expect(updatedMail.is_unread).toBe(0);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (jwt) await deleteAddress(request, jwt);
|
||||||
|
} finally {
|
||||||
|
await request.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
FRONTEND_URL,
|
||||||
|
FRONTEND_URL_ENV_OFF,
|
||||||
|
WORKER_URL,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
|
||||||
|
const uniqueValue = (label: string) => (
|
||||||
|
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createCode(
|
||||||
|
request: APIRequestContext,
|
||||||
|
type: 'role' | 'send_balance' | 'address_prefix_once',
|
||||||
|
value: string,
|
||||||
|
) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: type,
|
||||||
|
value,
|
||||||
|
enabled: true,
|
||||||
|
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.codes).toHaveLength(1);
|
||||||
|
return body.codes[0] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCode(request: APIRequestContext, type: string, code: string) {
|
||||||
|
const response = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||||
|
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.results.find((row: { code: string }) => row.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCode(request: APIRequestContext, type: string, code: string) {
|
||||||
|
const row = await findCode(request, type, code);
|
||||||
|
if (!row) return;
|
||||||
|
await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `${uniqueValue('browser-redeem-user')}@test.example.com`;
|
||||||
|
const password = createHash('sha256').update('browser-redeem-password').digest('hex');
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const usersResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const user = (await usersResponse.json()).results[0];
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { user_id: user.id, role_text: null },
|
||||||
|
});
|
||||||
|
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginResponse.ok()).toBe(true);
|
||||||
|
return { id: user.id as number, email, jwt: (await loginResponse.json()).jwt as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { user_id: userId, role_text: null },
|
||||||
|
});
|
||||||
|
await request.delete(`${WORKER_URL}/admin/users/${userId}`, { headers: ADMIN_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the mailbox entry opens the pure redemption page and completes role redemption', async ({ page, request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const code = await createCode(request, 'role', 'case-role');
|
||||||
|
try {
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-entry').click();
|
||||||
|
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||||
|
await expect(page.getByTestId('redeem-code-link')).toHaveAttribute(
|
||||||
|
'href', 'https://example.com/redeem-codes',
|
||||||
|
);
|
||||||
|
await page.evaluate((jwt) => localStorage.setItem('userJwt', jwt), user.jwt);
|
||||||
|
const settingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname === '/user_api/settings'
|
||||||
|
));
|
||||||
|
await page.reload();
|
||||||
|
await settingsResponse;
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Role benefits' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('redeem-user-email')).toHaveCount(0);
|
||||||
|
await page.getByTestId('redeem-now').click();
|
||||||
|
const userEmailInput = page.getByTestId('redeem-user-email').locator('input');
|
||||||
|
await expect(userEmailInput).toHaveValue(user.email);
|
||||||
|
await userEmailInput.fill(user.email.toUpperCase());
|
||||||
|
const refreshedSettings = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname === '/user_api/settings' && response.ok()
|
||||||
|
));
|
||||||
|
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||||
|
const refreshedUser = await (await refreshedSettings).json();
|
||||||
|
expect(refreshedUser.user_role.role).toBe('case-role');
|
||||||
|
expect(refreshedUser.access_token).toEqual(expect.any(String));
|
||||||
|
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||||
|
await expect(page.getByText(user.email, { exact: false })).toBeVisible();
|
||||||
|
await expect(page.getByText('case-role', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
const row = await findCode(request, 'role', code);
|
||||||
|
expect(JSON.parse(row.result)).toMatchObject({ user_email: user.email, role: 'case-role' });
|
||||||
|
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
const nextQuery = page.waitForRequest((request) => (
|
||||||
|
new URL(request.url()).pathname === '/redeem_api/query'
|
||||||
|
));
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
expect((await nextQuery).headers()['x-user-access-token']).toBe(refreshedUser.access_token);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sending credits defaults to the current mailbox and keeps the target editable', async ({ page, request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rdb-');
|
||||||
|
const code = await createCode(request, 'send_balance', '3');
|
||||||
|
try {
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-entry').click();
|
||||||
|
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||||
|
await page.evaluate((jwt) => localStorage.setItem('jwt', jwt), address.jwt);
|
||||||
|
const settingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname === '/api/settings'
|
||||||
|
));
|
||||||
|
await page.reload();
|
||||||
|
await settingsResponse;
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await page.getByTestId('redeem-now').click();
|
||||||
|
const targetInput = page.getByTestId('redeem-target-address').locator('input');
|
||||||
|
await expect(targetInput).toHaveValue(address.address);
|
||||||
|
await targetInput.fill(address.address.toUpperCase());
|
||||||
|
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the special-address page shows and retrieves the same full credentials', async ({ page, request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'ui');
|
||||||
|
const name = `m${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let jwt = '';
|
||||||
|
try {
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/redeem`);
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Custom mailbox' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Unused', { exact: true })).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-address-name').locator('input').fill(name);
|
||||||
|
await page.getByRole('button', { name: 'Redeem mailbox' }).click();
|
||||||
|
|
||||||
|
const addressValue = page.getByTestId('address-credential-address');
|
||||||
|
const passwordValue = page.getByTestId('address-credential-password');
|
||||||
|
const jwtValue = page.getByTestId('address-credential-jwt');
|
||||||
|
await expect(addressValue).toHaveText(`ui${name}@test.example.com`);
|
||||||
|
const firstAddress = await addressValue.innerText();
|
||||||
|
const firstPassword = await passwordValue.innerText();
|
||||||
|
const firstJwt = await jwtValue.innerText();
|
||||||
|
expect(firstPassword).not.toBe('');
|
||||||
|
expect(firstJwt).not.toBe('');
|
||||||
|
jwt = firstJwt;
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await expect(page.getByText('Used', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText(firstAddress, { exact: false })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: 'View redemption result' }).click();
|
||||||
|
await expect(page.getByTestId('address-credential-address')).toHaveText(firstAddress);
|
||||||
|
await expect(page.getByTestId('address-credential-password')).toHaveText(firstPassword);
|
||||||
|
await expect(page.getByTestId('address-credential-jwt')).toHaveText(firstJwt);
|
||||||
|
} finally {
|
||||||
|
if (jwt) {
|
||||||
|
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin can batch-create, search, export, and display a redemption result', async ({ page, request }) => {
|
||||||
|
let code = '';
|
||||||
|
const user = await createUser(request);
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||||
|
await expect(page.getByTestId('redeem-admin-create')).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-admin-create').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByTestId('redeem-admin-count').locator('input').fill('1');
|
||||||
|
await dialog.getByTestId('redeem-admin-role').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: 'case-role' }).click();
|
||||||
|
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||||
|
await expect(page.getByText('Select a future expiration time')).toBeVisible();
|
||||||
|
const expirationInput = dialog.locator('.n-date-picker input');
|
||||||
|
await expirationInput.fill('2099-01-01 00:00:00');
|
||||||
|
await expirationInput.press('Enter');
|
||||||
|
const createResponsePromise = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'POST'
|
||||||
|
&& new URL(response.url()).pathname === '/admin/redeem_codes/batch'
|
||||||
|
));
|
||||||
|
const createdCodesDownload = page.waitForEvent('download');
|
||||||
|
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||||
|
const createResponse = await createResponsePromise;
|
||||||
|
code = (await createResponse.json()).codes[0];
|
||||||
|
expect((await createdCodesDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||||
|
await expect(page.getByText('Redemption codes generated: 1.')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-admin-search').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Search' }).click();
|
||||||
|
await expect(page.getByText(code, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const exportDownload = page.waitForEvent('download');
|
||||||
|
await page.getByTestId('redeem-admin-export').click();
|
||||||
|
await expect(page.getByText('Export rows (maximum 10000)')).toBeVisible();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Download CSV' }).click();
|
||||||
|
expect((await exportDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(redeemResponse.ok()).toBe(true);
|
||||||
|
await page.getByRole('button', { name: 'Search' }).click();
|
||||||
|
await expect(page.getByText(user.email, { exact: true })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
if (code) await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin ignores a stale list response after switching redemption type', async ({ page }) => {
|
||||||
|
let releaseRole!: () => void;
|
||||||
|
let markRoleRequested!: () => void;
|
||||||
|
let markRoleCompleted!: () => void;
|
||||||
|
const roleGate = new Promise<void>((resolve) => { releaseRole = resolve; });
|
||||||
|
const roleRequested = new Promise<void>((resolve) => { markRoleRequested = resolve; });
|
||||||
|
const roleCompleted = new Promise<void>((resolve) => { markRoleCompleted = resolve; });
|
||||||
|
|
||||||
|
await page.route('**/admin/redeem_codes?*', async (route) => {
|
||||||
|
const type = new URL(route.request().url()).searchParams.get('redeem_type');
|
||||||
|
if (type === 'role') {
|
||||||
|
markRoleRequested();
|
||||||
|
await roleGate;
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
count: 1,
|
||||||
|
results: [{
|
||||||
|
id: 1,
|
||||||
|
code: 'STALE-ROLE-CODE',
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
result: null,
|
||||||
|
enabled: 1,
|
||||||
|
expires_at: '2099-01-01 00:00:00',
|
||||||
|
redeemed_at: null,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
markRoleCompleted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
count: 1,
|
||||||
|
results: [{
|
||||||
|
id: 2,
|
||||||
|
code: 'CURRENT-BALANCE-CODE',
|
||||||
|
redeem_type: 'send_balance',
|
||||||
|
value: '20',
|
||||||
|
result: null,
|
||||||
|
enabled: 1,
|
||||||
|
expires_at: '2099-01-01 00:00:00',
|
||||||
|
redeemed_at: null,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||||
|
await roleRequested;
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-admin-filter-type').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: 'Sending credits' }).click();
|
||||||
|
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
releaseRole();
|
||||||
|
await roleCompleted;
|
||||||
|
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('STALE-ROLE-CODE', { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { locale, roleRequired, roleType, addressType, create, generate, prefixError } of [
|
||||||
|
{
|
||||||
|
locale: 'en', roleRequired: 'Select a role', roleType: 'Role benefits',
|
||||||
|
addressType: 'Custom mailbox', create: 'Batch Generate', generate: 'Generate',
|
||||||
|
prefixError: 'Use only letters and digits, up to 29 characters. Leave empty for no prefix.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: 'zh', roleRequired: '请选择角色', roleType: '角色权益',
|
||||||
|
addressType: '专属邮箱', create: '批量生成', generate: '生成',
|
||||||
|
prefixError: '前缀仅支持英文字母和数字,最多 29 个字符;留空表示无前缀。',
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`Admin validates missing role and invalid prefix in ${locale}`, async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||||
|
});
|
||||||
|
let createRequests = 0;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.method() === 'POST'
|
||||||
|
&& new URL(request.url()).pathname === '/admin/redeem_codes/batch') {
|
||||||
|
createRequests++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
|
||||||
|
await expect(page.getByTestId('redeem-admin-filter-type')).toContainText(roleType);
|
||||||
|
await page.getByRole('button', { name: create, exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||||
|
await expect(page.getByText(roleRequired, { exact: true })).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: 'close', exact: true }).click();
|
||||||
|
await page.getByTestId('redeem-admin-filter-type').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: addressType }).click();
|
||||||
|
await page.getByRole('button', { name: create, exact: true }).click();
|
||||||
|
await dialog.locator('input[maxlength]').fill('bad-');
|
||||||
|
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||||
|
await expect(page.getByText(prefixError, { exact: true })).toBeVisible();
|
||||||
|
expect(createRequests).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the disabled frontend hides the entry and redirects the page', async ({ page }) => {
|
||||||
|
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/`);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/redeem`);
|
||||||
|
await expect(page).not.toHaveURL(/\/redeem$/);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/admin`);
|
||||||
|
await expect(page.getByTestId('redeem-admin-create')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Redemption Codes', { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
FRONTEND_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
requestSendAccess,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const expectEditorOriginsToAlign = async (page: Page) => {
|
||||||
|
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
|
||||||
|
const textareaElement = editor.querySelector('textarea');
|
||||||
|
const placeholder = editor.querySelector('.n-input__placeholder');
|
||||||
|
if (!textareaElement) throw new Error('compose textarea element not found');
|
||||||
|
if (!placeholder) throw new Error('compose placeholder element not found');
|
||||||
|
const textareaBox = textareaElement.getBoundingClientRect();
|
||||||
|
const placeholderBox = placeholder.getBoundingClientRect();
|
||||||
|
const textareaStyle = getComputedStyle(textareaElement);
|
||||||
|
const placeholderStyle = getComputedStyle(placeholder);
|
||||||
|
return {
|
||||||
|
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
|
||||||
|
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
|
||||||
|
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
|
||||||
|
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Send mail composer', () => {
|
||||||
|
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
|
||||||
|
const api = await apiRequest.newContext();
|
||||||
|
let jwt: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createTestAddress(api, 'compose-ui');
|
||||||
|
jwt = created.jwt;
|
||||||
|
await requestSendAccess(api, jwt);
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||||
|
await page.getByText('Send Mail', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator('.composer-title')).toContainText(created.address);
|
||||||
|
|
||||||
|
const expectedFieldOrder = [
|
||||||
|
'send-mail-sender-address',
|
||||||
|
'send-mail-sender-name',
|
||||||
|
'send-mail-recipient-address',
|
||||||
|
'send-mail-recipient-name',
|
||||||
|
];
|
||||||
|
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||||
|
(inputs) => inputs.map((input) => input.id)
|
||||||
|
);
|
||||||
|
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||||
|
|
||||||
|
const textarea = page.locator('.compose-textarea textarea');
|
||||||
|
await expectEditorOriginsToAlign(page);
|
||||||
|
|
||||||
|
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
|
||||||
|
const subject = page.getByRole('textbox', { name: /^Subject/ });
|
||||||
|
await recipient.fill('recipient@test.example.com');
|
||||||
|
await subject.fill('Composer preview');
|
||||||
|
|
||||||
|
await page.getByText('HTML', { exact: true }).click();
|
||||||
|
const htmlContent = [
|
||||||
|
'<h1>Preview heading</h1><p>Preview body</p>',
|
||||||
|
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
|
||||||
|
].join('');
|
||||||
|
let previewDialogAppeared = false;
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
previewDialogAppeared = true;
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await textarea.fill(htmlContent);
|
||||||
|
await page.getByRole('button', { name: 'Preview' }).click();
|
||||||
|
|
||||||
|
const preview = page.locator('.compose-preview');
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await expect(preview).toContainText('Preview heading');
|
||||||
|
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
|
||||||
|
expect(previewDialogAppeared).toBe(false);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
await expect(preview).toBeHidden();
|
||||||
|
await expect(recipient).toHaveValue('recipient@test.example.com');
|
||||||
|
await expect(subject).toHaveValue('Composer preview');
|
||||||
|
await expect(textarea).toHaveValue(htmlContent);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
|
||||||
|
await page.getByText('Enviar correo', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
|
||||||
|
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText('HTML', { exact: true }).click();
|
||||||
|
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
|
||||||
|
await expect(previewButton).toBeVisible();
|
||||||
|
const previewBox = await previewButton.boundingBox();
|
||||||
|
expect(previewBox).not.toBeNull();
|
||||||
|
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (jwt) await deleteAddress(api, jwt);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'mails');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||||
|
await page.getByText('Send Mail', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||||
|
const expectedFieldOrder = [
|
||||||
|
'admin-send-mail-sender-address',
|
||||||
|
'admin-send-mail-sender-name',
|
||||||
|
'admin-send-mail-recipient-address',
|
||||||
|
'admin-send-mail-recipient-name',
|
||||||
|
];
|
||||||
|
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||||
|
(inputs) => inputs.map((input) => input.id)
|
||||||
|
);
|
||||||
|
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||||
|
await expectEditorOriginsToAlign(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { FRONTEND_URL, WORKER_URL, createTestAddress, deleteAddress, hashPassword, getAddressSender, onMailpitMessage } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const accessToken = (expiresIn: number) => {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expiresIn })).toString('base64url');
|
||||||
|
return `e30.${payload}.signature`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApiTestPage = async (page: Page) => {
|
||||||
|
// Isolate API calls from the application's automatic settings requests.
|
||||||
|
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||||
|
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||||
|
}));
|
||||||
|
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } };
|
||||||
|
|
||||||
|
for (const scenario of [
|
||||||
|
{ name: 'mailbox credential', path: '/api/settings', site: false, admin: false },
|
||||||
|
{ name: 'account credential', path: '/user_api/settings', site: false, admin: false },
|
||||||
|
{ name: 'site password', path: '/api/settings', site: true, admin: false },
|
||||||
|
{ name: 'site password on admin API', path: '/admin/db_version', site: true, admin: false },
|
||||||
|
{ name: 'admin password', path: '/admin/db_version', site: false, admin: true },
|
||||||
|
]) {
|
||||||
|
test(`Authentication dialog distinguishes ${scenario.name}`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
await page.route(`**${scenario.path}`, route => route.fulfill(scenario.site
|
||||||
|
? { status: 401, json: { code: 'AUTH_SITE_PASSWORD_INVALID', message: 'Site password required' } }
|
||||||
|
: { status: 401, body: 'Invalid credential' }));
|
||||||
|
const result = await page.evaluate(async (path) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.openSettings.value.needAuth = true;
|
||||||
|
state.showAuth.value = false;
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
try {
|
||||||
|
await api.fetch(path);
|
||||||
|
} catch {
|
||||||
|
return { site: state.showAuth.value, admin: state.showAdminAuth.value };
|
||||||
|
}
|
||||||
|
}, scenario.path);
|
||||||
|
expect(result).toEqual({ site: scenario.site, admin: scenario.admin });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scenario of ['expired', 'expiring', 'valid', 'no account', 'login expired', 'wrong password', 'text zh', 'text en', 'retry expired', 'retry unauthorized', 'unmatched path', 'server error', 'json client error', 'json server error'] as const) {
|
||||||
|
test(`Access token response handling: ${scenario}`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
|
||||||
|
const initialToken = accessToken(scenario === 'valid' ? 3600 : scenario === 'expiring' ? 20 : -60);
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
const path = scenario === 'unmatched path' ? '/open_api/settings' : '/admin/db_version';
|
||||||
|
let refreshCount = 0;
|
||||||
|
const attempts: string[] = [];
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
expect(route.request().headers()['x-user-token']).toBe('account-token');
|
||||||
|
await route.fulfill(scenario === 'login expired'
|
||||||
|
? { status: 401, contentType: 'text/plain', body: 'Please login again' }
|
||||||
|
: { json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
await page.route(`**${path}`, async (route) => {
|
||||||
|
attempts.push(route.request().headers()['x-user-access-token']);
|
||||||
|
if (scenario.startsWith('json')) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: scenario === 'json client error' ? 403 : 503,
|
||||||
|
json: { code: 'OPERATION_FAILED', message: 'Operation failed' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scenario === 'server error') {
|
||||||
|
await route.fulfill({ status: 500, body: 'Server error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['valid', 'expiring', 'wrong password'].includes(scenario)
|
||||||
|
&& (attempts.length === 1 || scenario === 'retry expired')) {
|
||||||
|
await route.fulfill(scenario.startsWith('text')
|
||||||
|
? { status: 401, body: scenario === 'text zh' ? '您的访问令牌已过期, 请刷新页面' : 'Your access token has expired, please refresh the page' }
|
||||||
|
: expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill(['wrong password', 'retry unauthorized'].includes(scenario)
|
||||||
|
? { status: 401, contentType: 'text/plain', body: 'Admin password required' }
|
||||||
|
: { json: { current_db_version: 'test-version' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await page.evaluate(async ({ initialToken, scenario, path }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = scenario === 'no account' ? '' : 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = scenario === 'wrong password' ? 'wrong-password' : '';
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
try {
|
||||||
|
return { data: await api.fetch(path), error: null, showAdminAuth: state.showAdminAuth.value };
|
||||||
|
} catch (error) {
|
||||||
|
return { data: null, error: String(error), showAdminAuth: state.showAdminAuth.value };
|
||||||
|
}
|
||||||
|
}, { initialToken, scenario, path });
|
||||||
|
|
||||||
|
const needsRefresh = !['valid', 'expiring', 'no account', 'wrong password', 'text zh', 'text en', 'unmatched path', 'server error', 'json client error', 'json server error'].includes(scenario);
|
||||||
|
expect(refreshCount).toBe(needsRefresh ? 1 : 0);
|
||||||
|
expect(attempts).toEqual(needsRefresh && scenario !== 'login expired' ? [initialToken, freshToken] : [initialToken]);
|
||||||
|
expect(result.showAdminAuth).toBe(['wrong password', 'retry unauthorized'].includes(scenario) || scenario.startsWith('text'));
|
||||||
|
if (scenario === 'login expired') expect(result.error).toContain('Please login again');
|
||||||
|
else if (['wrong password', 'retry unauthorized'].includes(scenario)) expect(result.error).toContain('Admin password required');
|
||||||
|
else if (['retry expired', 'no account', 'unmatched path'].includes(scenario)) expect(result.error).toContain('Access token expired');
|
||||||
|
else if (scenario === 'server error') expect(result.error).toContain('Server error');
|
||||||
|
else if (scenario.startsWith('text')) {
|
||||||
|
expect(result.error).toContain(scenario === 'text zh'
|
||||||
|
? '您的访问令牌已过期, 请刷新页面'
|
||||||
|
: 'Your access token has expired, please refresh the page');
|
||||||
|
}
|
||||||
|
else if (scenario.startsWith('json')) {
|
||||||
|
expect(result.error).toContain('Operation failed');
|
||||||
|
expect(result.error).not.toContain('[object Object]');
|
||||||
|
}
|
||||||
|
else expect(result.data).toEqual({ current_db_version: 'test-version' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('concurrent and late responses share one access token refresh', async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const initialToken = accessToken(-60);
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
const paths = ['/admin/db_version', '/api/settings', '/api/send_mail',
|
||||||
|
'/user_api/bind_address', '/user_api/address/1/settings'];
|
||||||
|
let refreshCount = 0;
|
||||||
|
const initialPaths: string[] = [];
|
||||||
|
const attempts: string[] = [];
|
||||||
|
let release!: () => void;
|
||||||
|
let releaseLate!: () => void;
|
||||||
|
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const lateResponse = new Promise<void>((resolve) => { releaseLate = resolve; });
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
await refreshPending;
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||||
|
if (route.request().headers()['x-user-access-token'] === initialToken) {
|
||||||
|
const requestPath = new URL(route.request().url()).pathname;
|
||||||
|
initialPaths.push(requestPath);
|
||||||
|
if (requestPath === paths.at(-1)) await lateResponse;
|
||||||
|
await route.fulfill(expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts.push(route.request().headers()['x-user-access-token']);
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
const pending = page.evaluate(async ({ initialToken, paths }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
return Promise.all(paths.map((path) => api.fetch(path, {
|
||||||
|
method: path === '/api/send_mail' ? 'POST' : 'GET',
|
||||||
|
})));
|
||||||
|
}, { initialToken, paths });
|
||||||
|
try {
|
||||||
|
await expect.poll(() => refreshCount).toBe(1);
|
||||||
|
await expect.poll(() => initialPaths.length).toBe(paths.length);
|
||||||
|
expect(attempts).toEqual([]);
|
||||||
|
release();
|
||||||
|
await expect.poll(() => attempts.length).toBe(paths.length - 1);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
releaseLate();
|
||||||
|
}
|
||||||
|
expect(await pending).toEqual(paths.map(() => ({ success: true })));
|
||||||
|
expect(refreshCount).toBe(1);
|
||||||
|
expect(attempts).toEqual(paths.map(() => freshToken));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [400, 401, 500, 'network'] as const) {
|
||||||
|
test(`failed shared refresh preserves independent mailbox requests: ${failure}`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const paths = ['/admin/db_version', '/user_api/bind_address', '/user_api/address/1/settings',
|
||||||
|
'/api/settings', '/api/send_mail'];
|
||||||
|
const attempts: string[] = [];
|
||||||
|
let initialResponses = 0;
|
||||||
|
page.on('response', async (response) => {
|
||||||
|
if (paths.includes(new URL(response.url()).pathname) && response.status() === 401) {
|
||||||
|
await response.finished();
|
||||||
|
initialResponses++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let refreshCount = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
if (refreshCount > 1) {
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refreshPending;
|
||||||
|
if (failure === 'network') await route.abort('failed');
|
||||||
|
else await route.fulfill({ status: failure, body: 'Refresh failed' });
|
||||||
|
});
|
||||||
|
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
if (route.request().headers()['x-user-access-token'] && route.request().headers()['x-user-access-token'] !== freshToken) {
|
||||||
|
await route.fulfill(expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts.push(path);
|
||||||
|
await route.fulfill(path === '/api/send_mail'
|
||||||
|
? { status: 403, body: 'No send balance' }
|
||||||
|
: { json: { success: true } });
|
||||||
|
});
|
||||||
|
const pending = page.evaluate(async ({ paths, initialToken }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
state.openSettings.value.needAuth = true;
|
||||||
|
state.showAuth.value = false;
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
const results = await Promise.all(paths.map(async (path) => {
|
||||||
|
try {
|
||||||
|
return { data: await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' }) };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: String(error) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const flags = { showAuth: state.showAuth.value, showAdminAuth: state.showAdminAuth.value, loading: state.loading.value };
|
||||||
|
await api.fetch('/admin/db_version');
|
||||||
|
return { results, flags };
|
||||||
|
}, { paths, initialToken: accessToken(-60) });
|
||||||
|
try {
|
||||||
|
await expect.poll(() => initialResponses).toBe(paths.length);
|
||||||
|
await expect.poll(() => refreshCount).toBe(1);
|
||||||
|
expect(attempts).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
const { results, flags } = await pending;
|
||||||
|
for (const result of results.slice(0, 3)) {
|
||||||
|
expect(result.error).toContain(failure === 'network' ? 'Network Error' : 'Refresh failed');
|
||||||
|
}
|
||||||
|
expect(results[3].data).toEqual({ success: true });
|
||||||
|
expect(results[4].error).toContain('[403]: No send balance');
|
||||||
|
expect(flags).toEqual({ showAuth: false, showAdminAuth: false, loading: false });
|
||||||
|
expect(refreshCount).toBe(2);
|
||||||
|
expect(attempts.slice(0, 2).sort()).toEqual(['/api/send_mail', '/api/settings']);
|
||||||
|
expect(attempts.slice(2)).toEqual(['/admin/db_version']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const changedCredential of ['account', 'mailbox'] as const) {
|
||||||
|
test(`a changed ${changedCredential} stops the waiting request after refresh`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const path = changedCredential === 'mailbox' ? '/api/send_mail' : '/admin/db_version';
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
let attempts = 0;
|
||||||
|
await page.route(`**${path}`, async (route) => {
|
||||||
|
attempts++;
|
||||||
|
await route.fulfill(attempts === 1 ? expiredTokenResponse : { json: { success: true } });
|
||||||
|
});
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
await page.evaluate(async (changedCredential) => {
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
if (changedCredential === 'account') {
|
||||||
|
state.userJwt.value = 'other-account';
|
||||||
|
state.userSettings.value = { access_token: 'other-access-token' };
|
||||||
|
} else {
|
||||||
|
state.jwt.value = 'other-mailbox';
|
||||||
|
}
|
||||||
|
}, changedCredential);
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
const result = await page.evaluate(async ({ initialToken, path }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.jwt.value = 'mailbox-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
try {
|
||||||
|
await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return { error: String(error), token: state.userSettings.value.access_token, loading: state.loading.value };
|
||||||
|
}
|
||||||
|
}, { initialToken: accessToken(-60), path });
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: 'Error: User session changed, please retry',
|
||||||
|
token: changedCredential === 'account' ? 'other-access-token' : freshToken,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [scenario, query] of [
|
||||||
|
['expired account', ''],
|
||||||
|
['deleted account', ''],
|
||||||
|
['valid account', ''],
|
||||||
|
['missing account', ''],
|
||||||
|
['missing expiry', ''],
|
||||||
|
['expired account', '?refresh=1&source=e2e'],
|
||||||
|
['deleted account', '?refresh=1&source=e2e'],
|
||||||
|
['valid account', '?refresh=1&source=e2e'],
|
||||||
|
['missing account', '?refresh=1&source=e2e'],
|
||||||
|
['missing expiry', '?refresh=1&source=e2e'],
|
||||||
|
] as const) {
|
||||||
|
test(`mailbox operations use the real Worker when ${scenario}, query: ${query || 'none'}`, async ({ page, request }) => {
|
||||||
|
const address = await createTestAddress(request, 'refresh-mailbox-');
|
||||||
|
let userId: number | undefined;
|
||||||
|
try {
|
||||||
|
const email = `refresh-account-${Date.now()}@test.example.com`;
|
||||||
|
const created = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
data: { email, password: hashPassword('test-password-123') },
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const users = await request.get(`${WORKER_URL}/admin/users`, {
|
||||||
|
params: { limit: 10, offset: 0, query: email },
|
||||||
|
});
|
||||||
|
expect(users.ok()).toBe(true);
|
||||||
|
userId = (await users.json()).results.find((user: any) => user.user_email === email).id;
|
||||||
|
|
||||||
|
const sign = (payload: Record<string, unknown>) => {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', 'e2e-test-secret-key').update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
};
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const claims = { user_id: userId, user_email: email, iat: now - 3600 };
|
||||||
|
const userJwt = scenario === 'missing account' ? ''
|
||||||
|
: sign({ ...claims, exp: now + (scenario === 'expired account' ? -60 : 3600) });
|
||||||
|
const expiredAccessToken = sign({ ...claims, exp: scenario === 'missing expiry' ? undefined : now - 60, user_role: 'case-role' });
|
||||||
|
if (scenario === 'deleted account') {
|
||||||
|
expect((await request.delete(`${WORKER_URL}/admin/users/${userId}`)).ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the browser API module without the UI's automatic settings requests.
|
||||||
|
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||||
|
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||||
|
}));
|
||||||
|
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||||
|
const refreshStatuses: number[] = [];
|
||||||
|
let sendCount = 0;
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (new URL(response.url()).pathname === '/user_api/settings') refreshStatuses.push(response.status());
|
||||||
|
});
|
||||||
|
page.on('request', (req) => {
|
||||||
|
const url = new URL(req.url());
|
||||||
|
if (['/api/settings', '/api/send_mail'].includes(url.pathname)) expect(url.search).toBe(query);
|
||||||
|
if (url.pathname === '/api/send_mail') sendCount++;
|
||||||
|
});
|
||||||
|
const settings = await page.evaluate(async ({ userJwt, expiredAccessToken, mailboxJwt, query }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = userJwt;
|
||||||
|
state.userSettings.value.access_token = expiredAccessToken;
|
||||||
|
state.jwt.value = mailboxJwt;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
return api.fetch(`/api/settings${query}`);
|
||||||
|
}, { userJwt, expiredAccessToken, mailboxJwt: address.jwt, query });
|
||||||
|
expect(settings.address).toBe(address.address);
|
||||||
|
expect(settings.send_balance).toBe(10);
|
||||||
|
|
||||||
|
const subject = `Mailbox refresh regression ${scenario} ${Date.now()}`;
|
||||||
|
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
const [mail] = await Promise.all([
|
||||||
|
listener.message,
|
||||||
|
page.evaluate(async ({ subject, query }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
await api.fetch(`/api/send_mail${query}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { to_mail: 'recipient@test.example.com', subject, content: 'Independent mailbox credential', is_html: false },
|
||||||
|
});
|
||||||
|
}, { subject, query }),
|
||||||
|
]);
|
||||||
|
expect(mail.From.Address).toBe(address.address);
|
||||||
|
expect(sendCount).toBe(scenario === 'valid account' || scenario === 'missing expiry' ? 1 : 2);
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(9);
|
||||||
|
const sent = await request.get(`${WORKER_URL}/admin/sendbox`, {
|
||||||
|
params: { address: address.address, limit: 10, offset: 0 },
|
||||||
|
});
|
||||||
|
expect(sent.ok()).toBe(true);
|
||||||
|
expect((await sent.json()).results).toHaveLength(1);
|
||||||
|
expect(refreshStatuses).toEqual(scenario === 'valid account' ? [200]
|
||||||
|
: scenario === 'missing account' || scenario === 'missing expiry' ? []
|
||||||
|
: scenario === 'expired account' ? [401, 401] : [400, 400]);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||||
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FRONTEND_URL,
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
hashPassword,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
async function saveUserSettings(request: APIRequestContext, settings: Record<string, unknown>) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/user_settings`, { data: settings });
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `address-browser-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
const registerResponse = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(registerResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginResponse.ok()).toBe(true);
|
||||||
|
const { jwt } = await loginResponse.json();
|
||||||
|
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
return { email, jwt, userId: payload.user_id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindAddress(request: APIRequestContext, userJwt: string, addressJwt: string) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${addressJwt}`,
|
||||||
|
'x-user-token': userJwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('User address pagination browser flow', () => {
|
||||||
|
test('paginates addresses and filters mail', async ({ page }) => {
|
||||||
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
|
const request = await apiRequest.newContext();
|
||||||
|
const createdAddresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
let userId: number | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsResponse.json();
|
||||||
|
await saveUserSettings(request, {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
userId = user.userId;
|
||||||
|
|
||||||
|
for (let index = 0; index < 21; index += 1) {
|
||||||
|
const address = await createTestAddress(request, `browser-page-${index}-`);
|
||||||
|
createdAddresses.push(address);
|
||||||
|
await bindAddress(request, user.jwt, address.jwt);
|
||||||
|
}
|
||||||
|
const defaultAddressPageResponse = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/bind_address`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(defaultAddressPageResponse.ok()).toBe(true);
|
||||||
|
const defaultAddressPage = await defaultAddressPageResponse.json();
|
||||||
|
expect(defaultAddressPage.count).toBe(21);
|
||||||
|
expect(defaultAddressPage.results).toHaveLength(20);
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await page.evaluate((userJwt) => {
|
||||||
|
localStorage.setItem('userJwt', userJwt);
|
||||||
|
}, user.jwt);
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||||
|
|
||||||
|
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||||
|
const pagination = page.locator('.n-pagination').first();
|
||||||
|
const addressRows = page.locator('.n-data-table-tbody .n-data-table-tr');
|
||||||
|
await expect(pagination).toContainText(/Total:\s*21/);
|
||||||
|
await expect(addressRows).toHaveCount(20);
|
||||||
|
|
||||||
|
await pagination.locator('.n-pagination-item').filter({ hasText: /^2$/ }).click();
|
||||||
|
await expect(addressRows).toHaveCount(1);
|
||||||
|
|
||||||
|
const selectedAddress = createdAddresses[20];
|
||||||
|
|
||||||
|
const initialMailboxAddressesResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return url.pathname === '/user_api/bind_address'
|
||||||
|
&& url.searchParams.get('limit') === '100';
|
||||||
|
});
|
||||||
|
await page.getByText('Inbox', { exact: true }).click();
|
||||||
|
const initialMailboxResponse = await initialMailboxAddressesResponse;
|
||||||
|
expect(initialMailboxResponse.ok()).toBe(true);
|
||||||
|
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
|
||||||
|
await mailboxAddressSelect.click();
|
||||||
|
|
||||||
|
const mailboxOptions = page.locator('.n-base-select-menu:visible');
|
||||||
|
await expect(mailboxOptions).toContainText(selectedAddress.address);
|
||||||
|
const filteredMailResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return url.pathname === '/user_api/mails'
|
||||||
|
&& url.searchParams.get('address') === selectedAddress.address;
|
||||||
|
});
|
||||||
|
await mailboxOptions.getByText(selectedAddress.address, { exact: true }).click();
|
||||||
|
expect((await filteredMailResponse).ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(createdAddresses.map((address) => deleteAddress(request, address.jwt)));
|
||||||
|
if (userId !== undefined) {
|
||||||
|
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalUserSettings) {
|
||||||
|
await saveUserSettings(request, originalUserSettings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await request.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FRONTEND_URL,
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
hashPassword,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `user-send-browser-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt } = await loginRes.json();
|
||||||
|
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
return { email, jwt, userId: payload.user_id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('User send mail page', () => {
|
||||||
|
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
|
||||||
|
const request = await apiRequest.newContext();
|
||||||
|
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let userId: number | undefined;
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsRes.json();
|
||||||
|
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
userId = user.userId;
|
||||||
|
const address = await createTestAddress(request, 'usr-browser-');
|
||||||
|
const secondAddress = await createTestAddress(request, 'usr-second-');
|
||||||
|
addresses.push(address, secondAddress);
|
||||||
|
for (const boundAddress of addresses) {
|
||||||
|
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${boundAddress.jwt}`,
|
||||||
|
'x-user-token': user.jwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bindRes.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await page.evaluate((userJwt) => {
|
||||||
|
localStorage.setItem('userJwt', userJwt);
|
||||||
|
}, user.jwt);
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||||
|
|
||||||
|
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||||
|
const credentialResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'GET'
|
||||||
|
&& new URL(response.url()).pathname
|
||||||
|
=== `/user_api/bind_address_jwt/${address.address_id}`
|
||||||
|
));
|
||||||
|
const addressRow = page.getByRole('row').filter({ hasText: address.address });
|
||||||
|
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
|
||||||
|
expect((await credentialResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('dialog')).toContainText(address.address);
|
||||||
|
await page.getByRole('button', { name: 'close' }).click();
|
||||||
|
|
||||||
|
const initialSettingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname
|
||||||
|
=== `/user_api/address/${secondAddress.address_id}/settings`
|
||||||
|
));
|
||||||
|
await page.getByText('Send Mail', { exact: true }).click();
|
||||||
|
expect((await initialSettingsResponse).ok()).toBe(true);
|
||||||
|
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
|
||||||
|
|
||||||
|
const settingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname
|
||||||
|
=== `/user_api/address/${address.address_id}/settings`
|
||||||
|
));
|
||||||
|
await page.locator('.address-picker-select').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
|
||||||
|
expect((await settingsResponse).ok()).toBe(true);
|
||||||
|
await expect(page.locator('.address-picker-select')).toContainText(address.address);
|
||||||
|
|
||||||
|
const subject = `Browser user send ${Date.now()}`;
|
||||||
|
await page.getByRole('textbox', { name: /^Recipient address/ })
|
||||||
|
.fill('recipient@test.example.com');
|
||||||
|
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
|
||||||
|
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
|
||||||
|
|
||||||
|
const sendResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'POST'
|
||||||
|
&& new URL(response.url()).pathname
|
||||||
|
=== `/user_api/address/${address.address_id}/send_mail`
|
||||||
|
));
|
||||||
|
const sendboxResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'GET'
|
||||||
|
&& new URL(response.url()).pathname === '/user_api/sendbox'
|
||||||
|
));
|
||||||
|
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||||
|
expect((await sendResponse).ok()).toBe(true);
|
||||||
|
expect((await sendboxResponse).ok()).toBe(true);
|
||||||
|
|
||||||
|
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
|
||||||
|
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
|
||||||
|
.toHaveText(subject, { timeout: 15_000 });
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||||
|
if (userId !== undefined) {
|
||||||
|
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalUserSettings) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: originalUserSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await request.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,8 +132,10 @@ test.describe('IMAP Proxy', () => {
|
|||||||
try {
|
try {
|
||||||
const results = await client.search({ all: true });
|
const results = await client.search({ all: true });
|
||||||
expect(results.length).toBeGreaterThan(0);
|
expect(results.length).toBeGreaterThan(0);
|
||||||
const msg = await client.fetchOne(String(results[0]), { uid: true, flags: true }, { uid: true });
|
const seqMsg = await client.fetchOne(String(results[0]), { uid: true, flags: true });
|
||||||
expect(msg.uid).toBe(results[0]);
|
expect(seqMsg.uid).toBeGreaterThan(0);
|
||||||
|
const uidMsg = await client.fetchOne(String(seqMsg.uid), { uid: true, flags: true }, { uid: true });
|
||||||
|
expect(uidMsg.uid).toBe(seqMsg.uid);
|
||||||
} finally {
|
} finally {
|
||||||
lock.release();
|
lock.release();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let onMailpitMessage;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||||
|
await once(server, 'listening');
|
||||||
|
const originalApi = process.env.MAILPIT_API;
|
||||||
|
process.env.MAILPIT_API = `http://127.0.0.1:${server.address().port}/api`;
|
||||||
|
try {
|
||||||
|
({ onMailpitMessage } = await import('../../fixtures/test-helpers.ts'));
|
||||||
|
} finally {
|
||||||
|
if (originalApi === undefined) delete process.env.MAILPIT_API;
|
||||||
|
else process.env.MAILPIT_API = originalApi;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
for (const client of server.clients) client.terminate();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = { Subject: 'target-message' };
|
||||||
|
const newEvent = JSON.stringify({ Type: 'new', Data: target });
|
||||||
|
const statsEvent = JSON.stringify({ Type: 'stats', Data: { total: 1 } });
|
||||||
|
|
||||||
|
for (const [name, payload] of [
|
||||||
|
['single event', newEvent],
|
||||||
|
['new event followed by stats', `${newEvent}\n${statsEvent}`],
|
||||||
|
['stats followed by new event', `${statsEvent}\n${newEvent}`],
|
||||||
|
['malformed and unrelated events before the match',
|
||||||
|
`invalid-json\n${JSON.stringify({ Type: 'new', Data: { Subject: 'other' } })}\n${newEvent}\n`],
|
||||||
|
]) {
|
||||||
|
test(name, async () => {
|
||||||
|
const connected = once(server, 'connection');
|
||||||
|
const listener = onMailpitMessage((mail) => mail.Subject === target.Subject, { timeout: 1000 });
|
||||||
|
const received = assert.doesNotReject(async () => {
|
||||||
|
assert.deepEqual(await listener.message, target);
|
||||||
|
});
|
||||||
|
const [client] = await connected;
|
||||||
|
await listener.ready;
|
||||||
|
client.send(payload);
|
||||||
|
await received;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
||||||
|
VITE_DEFAULT_LANG=zh
|
||||||
VITE_CF_WEB_ANALY_TOKEN=
|
VITE_CF_WEB_ANALY_TOKEN=
|
||||||
VITE_IS_TELEGRAM=false
|
VITE_IS_TELEGRAM=false
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/logo.png" sizes="any">
|
<link rel="icon" href="/logo.png" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="/logo.png">
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
|
<script id="app-config" type="application/json">{}</script>
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
+18
-18
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloudflare_temp_email",
|
"name": "cloudflare_temp_email",
|
||||||
"version": "1.8.0",
|
"version": "1.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,42 +18,42 @@
|
|||||||
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
|
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
|
||||||
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
|
"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": "vitest run --passWithNoTests",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||||
"@simplewebauthn/browser": "13.2.2",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@unhead/vue": "^2.1.13",
|
"@unhead/vue": "^2.1.17",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.4.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.20.0",
|
||||||
"dompurify": "^3.4.0",
|
"dompurify": "^3.4.15",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"mail-parser-wasm": "^0.2.2",
|
"mail-parser-wasm": "^0.2.2",
|
||||||
"naive-ui": "^2.44.1",
|
"naive-ui": "^2.45.3",
|
||||||
"postal-mime": "^2.7.4",
|
"postal-mime": "^2.7.6",
|
||||||
"vooks": "^0.2.12",
|
"vooks": "^0.2.12",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.42",
|
||||||
"vue-clipboard3": "^2.0.0",
|
"vue-clipboard3": "^2.0.0",
|
||||||
"vue-i18n": "^11.3.2",
|
"vue-i18n": "^11.4.10",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vicons/fa": "^0.13.0",
|
"@vicons/fa": "^0.13.0",
|
||||||
"@vicons/material": "^0.13.0",
|
"@vicons/material": "^0.13.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"unplugin-auto-import": "^20.3.0",
|
"unplugin-auto-import": "^20.3.0",
|
||||||
"unplugin-vue-components": "^30.0.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"vite": "^7.3.2",
|
"vite": "^7.3.6",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vite-plugin-wasm": "^3.6.0",
|
"vite-plugin-wasm": "^3.6.0",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^4.1.11",
|
||||||
"workbox-build": "^7.4.0",
|
"workbox-build": "^7.4.1",
|
||||||
"workbox-window": "^7.4.0",
|
"workbox-window": "^7.4.1",
|
||||||
"wrangler": "^4.83.0"
|
"wrangler": "^4.129.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2096
-2134
File diff suppressed because it is too large
Load Diff
+36
-15
@@ -1,33 +1,44 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
|
import {
|
||||||
import { computed, onMounted } from 'vue'
|
darkTheme,
|
||||||
import { useScript } from '@unhead/vue'
|
} from 'naive-ui'
|
||||||
|
import { computed, onMounted, watchEffect } from 'vue'
|
||||||
|
import { useHead } from '@unhead/vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalState } from './store'
|
import { useGlobalState } from './store'
|
||||||
import { useIsMobile } from './utils/composables'
|
import { useIsMobile } from './utils/composables'
|
||||||
import Header from './views/Header.vue';
|
import Header from './views/Header.vue';
|
||||||
import Footer from './views/Footer.vue';
|
import Footer from './views/Footer.vue';
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
|
import { getNaiveLocaleConfig } from './i18n/naive-locale'
|
||||||
|
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
|
||||||
|
import { APP_CONFIG } from './config'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
|
const adClient = APP_CONFIG.GOOGLE_AD_CLIENT;
|
||||||
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
|
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT;
|
||||||
const { locale } = useI18n({});
|
const { locale } = useI18n({ useScope: 'global' });
|
||||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||||
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
|
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
|
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
|
||||||
const showAd = computed(() => !isMobile.value && adClient && adSlot);
|
const showAd = computed(() => !isMobile.value && adClient && adSlot);
|
||||||
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
|
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
|
||||||
|
|
||||||
// Load Google Ad script at top level (not inside onMounted)
|
watchEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
|
||||||
|
})
|
||||||
|
|
||||||
if (showAd.value) {
|
if (showAd.value) {
|
||||||
useScript({
|
useHead({
|
||||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
script: [{
|
||||||
async: true,
|
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||||
crossorigin: "anonymous",
|
async: true,
|
||||||
|
crossorigin: 'anonymous',
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +49,7 @@ onMounted(async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
const token = APP_CONFIG.CF_WEB_ANALY_TOKEN;
|
||||||
|
|
||||||
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
||||||
if (token && !exist) {
|
if (token && !exist) {
|
||||||
@@ -57,7 +68,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
// check if telegram is enabled
|
// check if telegram is enabled
|
||||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
const enableTelegram = APP_CONFIG.IS_TELEGRAM;
|
||||||
if (
|
if (
|
||||||
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
||||||
||
|
||
|
||||||
@@ -77,7 +88,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-config-provider :locale="localeConfig" :theme="theme">
|
<n-config-provider :locale="localeConfig.locale" :date-locale="localeConfig.dateLocale" :theme="theme">
|
||||||
<n-global-style />
|
<n-global-style />
|
||||||
<n-spin description="loading..." :show="loading">
|
<n-spin description="loading..." :show="loading">
|
||||||
<n-notification-provider container-style="margin-top: 60px;">
|
<n-notification-provider container-style="margin-top: 60px;">
|
||||||
@@ -120,6 +131,16 @@ onMounted(async () => {
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) and (pointer: coarse) and (max-width: 1024px) {
|
||||||
|
:where(input, textarea, select, [contenteditable="true"]) {
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(.n-input, .n-input-number, .n-base-selection, .n-input-group-label) {
|
||||||
|
--n-font-size: 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const setRuntimeConfig = (config) => {
|
||||||
|
const element = document.createElement('script')
|
||||||
|
element.id = 'app-config'
|
||||||
|
element.type = 'application/json'
|
||||||
|
element.textContent = JSON.stringify(config)
|
||||||
|
document.head.appendChild(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('APP_CONFIG', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.stubEnv('VITE_API_BASE', 'https://build.example.com')
|
||||||
|
vi.stubEnv('VITE_DEFAULT_LANG', 'zh')
|
||||||
|
vi.stubEnv('VITE_IS_TELEGRAM', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector('#app-config')?.remove()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses build settings when runtime settings are absent', async () => {
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides only settings provided by index.html', async () => {
|
||||||
|
setRuntimeConfig({ API_BASE: 'https://runtime.example.com', DEFAULT_LANG: 'en' })
|
||||||
|
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('https://runtime.example.com')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('en')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows an explicit empty runtime value', async () => {
|
||||||
|
setRuntimeConfig({ API_BASE: '' })
|
||||||
|
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to build settings for invalid runtime value types', async () => {
|
||||||
|
setRuntimeConfig({ API_BASE: {}, DEFAULT_LANG: 1, IS_TELEGRAM: [] })
|
||||||
|
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||||
|
expect(APP_CONFIG.IS_TELEGRAM).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads runtime settings only once', async () => {
|
||||||
|
setRuntimeConfig({ DEFAULT_LANG: 'en' })
|
||||||
|
const firstImport = await import('../config')
|
||||||
|
|
||||||
|
document.querySelector('#app-config').textContent = JSON.stringify({ DEFAULT_LANG: 'de' })
|
||||||
|
const secondImport = await import('../config')
|
||||||
|
|
||||||
|
expect(secondImport.APP_CONFIG).toBe(firstImport.APP_CONFIG)
|
||||||
|
expect(secondImport.APP_CONFIG.DEFAULT_LANG).toBe('en')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const ErrorCode = {
|
||||||
|
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||||
|
AUTH_SITE_PASSWORD_INVALID: 'AUTH_SITE_PASSWORD_INVALID',
|
||||||
|
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||||
|
};
|
||||||
+55
-21
@@ -4,8 +4,13 @@ import axios from 'axios'
|
|||||||
|
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
import { getFingerprint } from '../utils/fingerprint'
|
import { getFingerprint } from '../utils/fingerprint'
|
||||||
|
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
||||||
|
import { sanitizeHtml } from '../utils/sanitize-html'
|
||||||
|
import { APP_CONFIG } from '../config'
|
||||||
|
import { isUserAccessTokenError, createUserAccessTokenInterceptor } from './user-access-token-interceptor'
|
||||||
|
import { ErrorCode } from './error-codes'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
const API_BASE = APP_CONFIG.API_BASE || "";
|
||||||
const {
|
const {
|
||||||
loading, auth, jwt, settings, openSettings,
|
loading, auth, jwt, settings, openSettings,
|
||||||
userOpenSettings, userSettings, announcement,
|
userOpenSettings, userSettings, announcement,
|
||||||
@@ -18,52 +23,75 @@ const instance = axios.create({
|
|||||||
validateStatus: (status) => status >= 200 && status <= 500
|
validateStatus: (status) => status >= 200 && status <= 500
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responseInterceptors = [createUserAccessTokenInterceptor(instance)];
|
||||||
|
|
||||||
|
const interceptResponse = async (path, response) => {
|
||||||
|
for (const { matches, handle } of responseInterceptors) {
|
||||||
|
if (matches(path, response)) return await handle(response);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
const apiFetch = async (path, options = {}) => {
|
const apiFetch = async (path, options = {}) => {
|
||||||
loading.value = true;
|
const showLoading = options.showLoading !== false;
|
||||||
|
if (showLoading) loading.value = true;
|
||||||
try {
|
try {
|
||||||
// Get browser fingerprint for request tracking
|
// Get browser fingerprint for request tracking
|
||||||
const fingerprint = await getFingerprint();
|
const fingerprint = await getFingerprint();
|
||||||
|
|
||||||
const response = await instance.request(path, {
|
// Skip auth headers whose value is empty / "undefined" / contains
|
||||||
|
// control chars (otherwise axios throws "Invalid character in header
|
||||||
|
// content" before the request is sent — see issue #1000).
|
||||||
|
const headers = {
|
||||||
|
'x-lang': i18n.global.locale.value,
|
||||||
|
'x-fingerprint': fingerprint,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
const userTokenHeader = safeHeaderValue(options.userJwt || userJwt.value);
|
||||||
|
if (userTokenHeader) headers['x-user-token'] = userTokenHeader;
|
||||||
|
const userAccessHeader = safeHeaderValue(userSettings.value.access_token);
|
||||||
|
if (userAccessHeader) headers['x-user-access-token'] = userAccessHeader;
|
||||||
|
const customAuthHeader = safeHeaderValue(auth.value);
|
||||||
|
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
|
||||||
|
const adminAuthHeader = safeHeaderValue(adminAuth.value);
|
||||||
|
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
|
||||||
|
const authorizationHeader = safeBearerHeader(jwt.value);
|
||||||
|
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
||||||
|
|
||||||
|
const initialResponse = await instance.request(path, {
|
||||||
method: options.method || 'GET',
|
method: options.method || 'GET',
|
||||||
data: options.body || null,
|
data: options.body || null,
|
||||||
headers: {
|
headers,
|
||||||
'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")) {
|
const response = await interceptResponse(path, initialResponse);
|
||||||
|
const isSiteAuthError = response.status === 401 && response.data?.code === ErrorCode.AUTH_SITE_PASSWORD_INVALID;
|
||||||
|
if (response.status === 401 && path.startsWith("/admin") && !isUserAccessTokenError(response) && !isSiteAuthError) {
|
||||||
showAdminAuth.value = true;
|
showAdminAuth.value = true;
|
||||||
}
|
}
|
||||||
if (response.status === 401 && openSettings.value.needAuth) {
|
if (isSiteAuthError && openSettings.value.needAuth) {
|
||||||
showAuth.value = true;
|
showAuth.value = true;
|
||||||
}
|
}
|
||||||
if (response.status >= 300) {
|
if (response.status >= 300) {
|
||||||
throw new Error(`[${response.status}]: ${response.data}` || "error");
|
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||||
}
|
}
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
throw new Error(`Code ${error.response.status}: ${error.response.data}` || "error");
|
throw new Error(`Code ${error.response.status}: ${error.response.data?.message || error.response.data}`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (showLoading) loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOpenSettings = async (message, notification) => {
|
const getOpenSettings = async (message, notification) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.fetch("/open_api/settings");
|
const res = await api.fetch("/open_api/settings");
|
||||||
|
const domains = Array.isArray(res["domains"]) ? res["domains"] : [];
|
||||||
const domainLabels = res["domainLabels"] || [];
|
const domainLabels = res["domainLabels"] || [];
|
||||||
if (res["domains"]?.length < 1) {
|
if (domains.length < 1) {
|
||||||
message.error("No domains found, please check your worker settings");
|
message.error("No domains found, please check your worker settings");
|
||||||
}
|
}
|
||||||
Object.assign(openSettings.value, {
|
Object.assign(openSettings.value, {
|
||||||
@@ -75,7 +103,7 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
needAuth: res["needAuth"] || false,
|
needAuth: res["needAuth"] || false,
|
||||||
defaultDomains: res["defaultDomains"] || [],
|
defaultDomains: res["defaultDomains"] || [],
|
||||||
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
|
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
|
||||||
domains: res["domains"].map((domain, index) => {
|
domains: domains.map((domain, index) => {
|
||||||
return {
|
return {
|
||||||
label: domainLabels.length > index ? domainLabels[index] : domain,
|
label: domainLabels.length > index ? domainLabels[index] : domain,
|
||||||
value: domain
|
value: domain
|
||||||
@@ -86,13 +114,19 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
||||||
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
||||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||||
|
enableMailReadStatus: res["enableMailReadStatus"] === true,
|
||||||
enableAutoReply: res["enableAutoReply"] || false,
|
enableAutoReply: res["enableAutoReply"] || false,
|
||||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||||
copyright: res["copyright"] || openSettings.value.copyright,
|
copyright: res["copyright"] || openSettings.value.copyright,
|
||||||
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||||
enableWebhook: res["enableWebhook"] || false,
|
enableWebhook: res["enableWebhook"] || false,
|
||||||
isS3Enabled: res["isS3Enabled"] || false,
|
isS3Enabled: res["isS3Enabled"] || false,
|
||||||
|
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
|
||||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||||
|
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
|
||||||
|
enableRedeemCode: res["enableRedeemCode"] || false,
|
||||||
|
redeemCodeUrl: res["redeemCodeUrl"] || "",
|
||||||
|
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
|
||||||
statusUrl: res["statusUrl"] || "",
|
statusUrl: res["statusUrl"] || "",
|
||||||
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
||||||
});
|
});
|
||||||
@@ -108,7 +142,7 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
notification.info({
|
notification.info({
|
||||||
content: () => {
|
content: () => {
|
||||||
return h("div", {
|
return h("div", {
|
||||||
innerHTML: announcement.value
|
innerHTML: sanitizeHtml(announcement.value)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { AxiosHeaders } from 'axios';
|
||||||
|
import { useGlobalState } from '../store';
|
||||||
|
import { safeBearerHeader, safeHeaderValue } from '../utils/headers';
|
||||||
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
|
const { userJwt, userSettings, jwt } = useGlobalState();
|
||||||
|
const mailboxPaths = new Set(['/api/settings', '/api/send_mail']);
|
||||||
|
const paths = ['/admin/', ...mailboxPaths, '/user_api/bind_address', '/user_api/address/'];
|
||||||
|
const getPathname = (url) => new URL(url, window.location.origin).pathname;
|
||||||
|
|
||||||
|
export const isUserAccessTokenError = (response) => response.status === 401
|
||||||
|
&& response.data?.code === ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED;
|
||||||
|
|
||||||
|
const isCurrentSession = ({ headers }) =>
|
||||||
|
safeHeaderValue(headers.get('x-user-token')) === safeHeaderValue(userJwt.value)
|
||||||
|
&& safeHeaderValue(headers.get('Authorization')) === safeBearerHeader(jwt.value);
|
||||||
|
|
||||||
|
const matches = (path, response) => {
|
||||||
|
if (!isUserAccessTokenError(response)) return false;
|
||||||
|
const pathname = getPathname(path);
|
||||||
|
if (!paths.some(prefix => pathname.startsWith(prefix))) return false;
|
||||||
|
const { config } = response;
|
||||||
|
if (!isCurrentSession(config)) return false;
|
||||||
|
if (!safeHeaderValue(config.headers.get('x-user-access-token'))) return false;
|
||||||
|
|
||||||
|
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||||
|
return Boolean(token) || mailboxPaths.has(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadUserSettings(token, client, headers) {
|
||||||
|
const response = await client.get('/user_api/settings', { headers });
|
||||||
|
if (response.status >= 300) {
|
||||||
|
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||||
|
}
|
||||||
|
if (safeHeaderValue(userJwt.value) === token) {
|
||||||
|
Object.assign(userSettings.value, response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUserAccessTokenInterceptor = (client) => {
|
||||||
|
const pendingRefreshes = new Map();
|
||||||
|
|
||||||
|
async function refreshUserSettings(token, headers) {
|
||||||
|
if (pendingRefreshes.has(token)) return await pendingRefreshes.get(token);
|
||||||
|
const request = loadUserSettings(token, client, headers);
|
||||||
|
pendingRefreshes.set(token, request);
|
||||||
|
try {
|
||||||
|
await request;
|
||||||
|
} finally {
|
||||||
|
pendingRefreshes.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAccessToken(config) {
|
||||||
|
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||||
|
if (!token) return;
|
||||||
|
const currentToken = safeHeaderValue(userSettings.value.access_token);
|
||||||
|
if (currentToken !== safeHeaderValue(config.headers.get('x-user-access-token'))) return currentToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshUserSettings(token, config.headers);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mailboxPaths.has(getPathname(config.url))) throw error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return safeHeaderValue(userSettings.value.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matches,
|
||||||
|
handle: async ({ config }) => {
|
||||||
|
const accessToken = await resolveAccessToken(config);
|
||||||
|
if (!isCurrentSession(config)) throw new Error('User session changed, please retry');
|
||||||
|
const headers = new AxiosHeaders(config.headers);
|
||||||
|
headers.delete('x-user-access-token');
|
||||||
|
if (accessToken) headers.set('x-user-access-token', accessToken);
|
||||||
|
return await client.request({ ...config, headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
import { APP_CONFIG } from '@/config'
|
||||||
|
|
||||||
|
import { useGlobalState } from '../store'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
addressPassword: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { openSettings, auth } = useGlobalState()
|
||||||
|
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const configuredApiBaseUrl = APP_CONFIG.API_BASE || ''
|
||||||
|
const frontendBaseUrl = computed(() => window.location.origin)
|
||||||
|
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
|
||||||
|
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
|
||||||
|
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
|
||||||
|
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
|
||||||
|
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
|
||||||
|
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
|
||||||
|
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
|
||||||
|
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
|
||||||
|
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
|
||||||
|
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
|
||||||
|
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
|
||||||
|
const securityLabel = computed(() =>
|
||||||
|
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
|
||||||
|
)
|
||||||
|
const agentConfigJson = computed(() => JSON.stringify({
|
||||||
|
base: apiBaseUrl.value,
|
||||||
|
jwt: props.jwt,
|
||||||
|
site_password: auth.value || '',
|
||||||
|
}, null, 2))
|
||||||
|
const agentText = computed(() => [
|
||||||
|
`${t('currentAddress')}: ${props.address || '-'}`,
|
||||||
|
`${t('apiBase')}: ${apiBaseUrl.value}`,
|
||||||
|
`${t('agentSkill')}: ${agentSkillUrl}`,
|
||||||
|
`${t('agentConfig')}:`,
|
||||||
|
agentConfigJson.value,
|
||||||
|
].join('\n'))
|
||||||
|
const smtpImapText = computed(() => [
|
||||||
|
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
|
||||||
|
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
|
||||||
|
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
|
||||||
|
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
|
||||||
|
`${t('security')}: ${securityLabel.value}`,
|
||||||
|
`${t('username')}: ${props.address || '-'}`,
|
||||||
|
`${t('password')}: ${props.jwt}`,
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
message.success(t('copySuccess'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
try {
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
if (document.execCommand('copy')) {
|
||||||
|
message.success(t('copySuccess'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.error(t('copyFailed'))
|
||||||
|
} finally {
|
||||||
|
textarea.parentNode?.removeChild(textarea)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
message.error(t('copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="credential-content">
|
||||||
|
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||||
|
{{ t('tip') }}
|
||||||
|
</n-alert>
|
||||||
|
<section class="credential-panel">
|
||||||
|
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
|
||||||
|
<div class="credential-section">
|
||||||
|
<div v-if="address" class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('currentAddress') }}</span>
|
||||||
|
<div class="credential-copy-row">
|
||||||
|
<code data-testid="address-credential-address" class="credential-code">{{ address }}</code>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
||||||
|
<div class="credential-copy-row">
|
||||||
|
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="addressPassword" class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('addressPassword') }}</span>
|
||||||
|
<code data-testid="address-credential-password" class="credential-code">{{ addressPassword }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<n-collapse accordion class="credential-collapse">
|
||||||
|
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<div class="credential-section">
|
||||||
|
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('apiBase') }}</span>
|
||||||
|
<code class="credential-code">{{ apiBaseUrl }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('agentSkill') }}</span>
|
||||||
|
<code class="credential-code">
|
||||||
|
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('agentConfig') }}</span>
|
||||||
|
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="credential-actions">
|
||||||
|
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||||
|
{{ t('docs') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-collapse-item>
|
||||||
|
|
||||||
|
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<div class="credential-section">
|
||||||
|
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
|
||||||
|
<div class="credential-grid">
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('smtpHost') }}</span>
|
||||||
|
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('smtpPort') }}</span>
|
||||||
|
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('imapHost') }}</span>
|
||||||
|
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('imapPort') }}</span>
|
||||||
|
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('security') }}</span>
|
||||||
|
<code class="credential-code">{{ securityLabel }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('username') }}</span>
|
||||||
|
<code class="credential-code">{{ address }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('password') }}</span>
|
||||||
|
<code class="credential-code">{{ jwt }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-actions">
|
||||||
|
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||||
|
{{ t('docs') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-collapse-item>
|
||||||
|
|
||||||
|
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<div class="credential-section">
|
||||||
|
<div class="credential-field">
|
||||||
|
<code class="credential-code">{{ autoLoginUrl }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.credential-collapse {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-tip {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-label {
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-code {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--n-color-embedded);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-copy-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-code-block {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.credential-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-copy-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
|
||||||
|
import AddressCredentialContent from './AddressCredentialContent.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
addressPassword: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show'])
|
||||||
|
const { t } = useScopedI18n('components.AddressCredentialModal')
|
||||||
|
|
||||||
|
const modalShow = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (value) => emit('update:show', value),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
|
||||||
|
style="width: min(760px, calc(100vw - 32px));">
|
||||||
|
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import useClipboard from 'vue-clipboard3'
|
import useClipboard from 'vue-clipboard3'
|
||||||
import { Copy } from '@vicons/fa'
|
import { Copy } from '@vicons/fa'
|
||||||
@@ -27,24 +27,7 @@ const {
|
|||||||
jwt, settings, userJwt, isTelegram, openSettings, telegramApp
|
jwt, settings, userJwt, isTelegram, openSettings, telegramApp
|
||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
|
|
||||||
const { t } = useI18n({
|
const { t } = useScopedI18n('components.AddressSelect')
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
userAddresses: 'User Addresses',
|
|
||||||
localAddresses: 'Local Addresses',
|
|
||||||
address: 'Address',
|
|
||||||
copy: 'Copy',
|
|
||||||
copied: 'Copied',
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
userAddresses: '用户地址',
|
|
||||||
localAddresses: '本地地址',
|
|
||||||
address: '地址',
|
|
||||||
copy: '复制',
|
|
||||||
copied: '已复制',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const addressOptions = ref([])
|
const addressOptions = ref([])
|
||||||
const addressValue = ref(null)
|
const addressValue = ref(null)
|
||||||
@@ -114,7 +97,7 @@ const buildLocalOptions = (excludeAddresses = new Set()) => {
|
|||||||
const buildUserOptions = async () => {
|
const buildUserOptions = async () => {
|
||||||
const children = [];
|
const children = [];
|
||||||
try {
|
try {
|
||||||
const { results } = await api.fetch(`/user_api/bind_address`);
|
const { results } = await api.fetch(`/user_api/bind_address?limit=100&offset=0`);
|
||||||
for (const row of results || []) {
|
for (const row of results || []) {
|
||||||
const address = row.address || row.name;
|
const address = row.address || row.name;
|
||||||
if (!address) continue;
|
if (!address) continue;
|
||||||
@@ -170,7 +153,7 @@ const refreshAddressOptions = async () => {
|
|||||||
if (userJwt.value) {
|
if (userJwt.value) {
|
||||||
const userChildren = await buildUserOptions();
|
const userChildren = await buildUserOptions();
|
||||||
if (userChildren.length > 0) {
|
if (userChildren.length > 0) {
|
||||||
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
|
groups.push({ type: 'group', label: t('linkedMailboxes'), children: userChildren });
|
||||||
}
|
}
|
||||||
const userAddressSet = new Set(userChildren.map((item) => item.address));
|
const userAddressSet = new Set(userChildren.map((item) => item.address));
|
||||||
const localChildren = buildLocalOptions(userAddressSet);
|
const localChildren = buildLocalOptions(userAddressSet);
|
||||||
@@ -232,7 +215,7 @@ watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
|||||||
<template>
|
<template>
|
||||||
<n-flex class="address-row" align="center" justify="center" :wrap="true">
|
<n-flex class="address-row" align="center" justify="center" :wrap="true">
|
||||||
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
|
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
|
||||||
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
|
:loading="addressLoading" :placeholder="t('selectMailbox')" @update:value="onAddressChange"
|
||||||
class="address-select" />
|
class="address-select" />
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
|
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useScopedI18n } from '@/i18n/app';
|
||||||
import { ContentCopyOutlined, LinkRound, CodeRound } from '@vicons/material';
|
import { ContentCopyOutlined, LinkRound, CodeRound } from '@vicons/material';
|
||||||
import { useMessage } from 'naive-ui';
|
import { useMessage } from 'naive-ui';
|
||||||
import { useGlobalState } from '../store';
|
import { useGlobalState } from '../store';
|
||||||
@@ -32,30 +32,7 @@ const tagThemeOverrides = computed(() => {
|
|||||||
return {}
|
return {}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n({
|
const { t } = useScopedI18n('components.AiExtractInfo')
|
||||||
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({
|
const props = defineProps({
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
|
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
|
||||||
import { useIsMobile } from '../utils/composables'
|
import { useIsMobile } from '../utils/composables'
|
||||||
@@ -55,12 +55,20 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
|
enableMailReadStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
updateMailReadStatus: {
|
||||||
|
type: Function,
|
||||||
|
default: () => { }
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const localFilterKeyword = ref('')
|
const localFilterKeyword = ref('')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
|
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
|
||||||
autoRefresh, configAutoRefreshInterval, sendMailModel
|
autoRefresh, configAutoRefreshInterval, sendMailModel
|
||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
|
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
|
||||||
@@ -71,6 +79,12 @@ const count = ref(0)
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
|
|
||||||
|
const mailListPreviewLineClampValue = computed(() => {
|
||||||
|
const value = Number(mailListPreviewLineClamp.value)
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
return Math.min(5, Math.max(0, Math.round(value)))
|
||||||
|
})
|
||||||
|
|
||||||
// Computed property for filtered data (only filter current page)
|
// Computed property for filtered data (only filter current page)
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
|
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
|
||||||
@@ -88,6 +102,29 @@ const data = computed(() => {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const openMail = (mail) => {
|
||||||
|
curMail.value = mail
|
||||||
|
if (mail?.is_unread !== 1 || !props.enableMailReadStatus) return
|
||||||
|
mail.is_unread = 0
|
||||||
|
void props.updateMailReadStatus(mail.id, false).catch(() => {
|
||||||
|
mail.is_unread = 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCurrentMailUnread = async () => {
|
||||||
|
if (!curMail.value || !props.enableMailReadStatus) return
|
||||||
|
const mail = curMail.value
|
||||||
|
const previousValue = mail.is_unread
|
||||||
|
const isUnread = previousValue !== 1
|
||||||
|
mail.is_unread = isUnread ? 1 : 0
|
||||||
|
try {
|
||||||
|
await props.updateMailReadStatus(mail.id, isUnread)
|
||||||
|
message.success(t("success"))
|
||||||
|
} catch {
|
||||||
|
mail.is_unread = previousValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canGoPrevMail = computed(() => {
|
const canGoPrevMail = computed(() => {
|
||||||
if (!curMail.value) return false
|
if (!curMail.value) return false
|
||||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
@@ -105,12 +142,12 @@ const prevMail = async () => {
|
|||||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
|
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
curMail.value = data.value[currentIndex - 1]
|
openMail(data.value[currentIndex - 1])
|
||||||
} else if (page.value > 1) {
|
} else if (page.value > 1) {
|
||||||
page.value--
|
page.value--
|
||||||
await refresh()
|
await refresh()
|
||||||
if (data.value.length > 0) {
|
if (data.value.length > 0) {
|
||||||
curMail.value = data.value[data.value.length - 1]
|
openMail(data.value[data.value.length - 1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,12 +157,12 @@ const nextMail = async () => {
|
|||||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
|
|
||||||
if (currentIndex < data.value.length - 1) {
|
if (currentIndex < data.value.length - 1) {
|
||||||
curMail.value = data.value[currentIndex + 1]
|
openMail(data.value[currentIndex + 1])
|
||||||
} else if (count.value > page.value * pageSize.value) {
|
} else if (count.value > page.value * pageSize.value) {
|
||||||
page.value++
|
page.value++
|
||||||
await refresh()
|
await refresh()
|
||||||
if (data.value.length > 0) {
|
if (data.value.length > 0) {
|
||||||
curMail.value = data.value[0]
|
openMail(data.value[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,60 +175,7 @@ const showMultiActionDelete = ref(false)
|
|||||||
const multiActionDownloadZip = ref({})
|
const multiActionDownloadZip = ref({})
|
||||||
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
|
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
|
||||||
|
|
||||||
const { t } = useI18n({
|
const { t } = useScopedI18n('components.MailBox')
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
success: 'Success',
|
|
||||||
autoRefresh: 'Auto Refresh',
|
|
||||||
refreshAfter: 'Refresh After {msg} Seconds',
|
|
||||||
refresh: 'Refresh',
|
|
||||||
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',
|
|
||||||
multiAction: 'Multi Action',
|
|
||||||
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: '成功',
|
|
||||||
autoRefresh: '自动刷新',
|
|
||||||
refreshAfter: '{msg}秒后刷新',
|
|
||||||
refresh: '刷新',
|
|
||||||
downloadMail: '下载邮件',
|
|
||||||
attachments: '查看附件',
|
|
||||||
pleaseSelectMail: "请选择邮件",
|
|
||||||
emptyInbox: "收件箱为空",
|
|
||||||
delete: '删除',
|
|
||||||
deleteMailTip: '确定要删除邮件吗?',
|
|
||||||
reply: '回复',
|
|
||||||
forwardMail: '转发',
|
|
||||||
showTextMail: '显示纯文本邮件',
|
|
||||||
showHtmlMail: '显示HTML邮件',
|
|
||||||
saveToS3: '保存到S3',
|
|
||||||
multiAction: '多选',
|
|
||||||
cancelMultiAction: '取消多选',
|
|
||||||
selectAll: '全选本页',
|
|
||||||
unselectAll: '取消全选',
|
|
||||||
prevMail: '上一封',
|
|
||||||
nextMail: '下一封',
|
|
||||||
keywordQueryTip: '过滤当前页',
|
|
||||||
query: '查询',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const setupAutoRefresh = async (autoRefresh) => {
|
const setupAutoRefresh = async (autoRefresh) => {
|
||||||
// auto refresh every configAutoRefreshInterval seconds
|
// auto refresh every configAutoRefreshInterval seconds
|
||||||
@@ -236,7 +220,7 @@ const refresh = async () => {
|
|||||||
count.value = totalCount;
|
count.value = totalCount;
|
||||||
}
|
}
|
||||||
curMail.value = null;
|
curMail.value = null;
|
||||||
if (!isMobile.value && data.value.length > 0) {
|
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
||||||
curMail.value = data.value[0];
|
curMail.value = data.value[0];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -252,12 +236,17 @@ const backFirstPageAndRefresh = async () => {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickRow = async (row) => {
|
const clickRow = (row) => {
|
||||||
if (multiActionMode.value) {
|
if (multiActionMode.value) {
|
||||||
row.checked = !row.checked;
|
row.checked = !row.checked;
|
||||||
|
curMail.value = row;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
curMail.value = row;
|
if (mailListView.value && curMail.value?.id === row.id) {
|
||||||
|
curMail.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openMail(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -428,13 +417,18 @@ onBeforeUnmount(() => {
|
|||||||
clearable />
|
clearable />
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</div>
|
||||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
<n-split class="left" direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
|
||||||
:on-update:size="onSpiltSizeChange">
|
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange" v-if="!mailListView || curMail">
|
||||||
|
<template #resize-trigger>
|
||||||
|
<div class="split-handle">
|
||||||
|
<div class="split-handle__grip" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #1>
|
<template #1>
|
||||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||||
:class="mailItemClass(row)">
|
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
|
||||||
<template #prefix v-if="multiActionMode">
|
<template #prefix v-if="multiActionMode">
|
||||||
<n-checkbox v-model:checked="row.checked" />
|
<n-checkbox v-model:checked="row.checked" />
|
||||||
</template>
|
</template>
|
||||||
@@ -465,15 +459,25 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template #2>
|
<template #2>
|
||||||
<div v-if="curMail" style="margin: 8px;">
|
<div v-if="curMail" style="margin: 8px;">
|
||||||
<n-flex justify="space-between">
|
<n-flex justify="space-between" align="center">
|
||||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
<n-space :wrap="false" align="center">
|
||||||
<template #icon>
|
<n-button v-if="mailListView" @click="curMail = null" text size="small">
|
||||||
<n-icon>
|
<template #icon>
|
||||||
<ArrowBackIosNewFilled />
|
<n-icon>
|
||||||
</n-icon>
|
<ArrowBackIosNewFilled />
|
||||||
</template>
|
</n-icon>
|
||||||
{{ t('prevMail') }}
|
</template>
|
||||||
</n-button>
|
{{ t('backToList') }}
|
||||||
|
</n-button>
|
||||||
|
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<ArrowBackIosNewFilled />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
{{ t('prevMail') }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
|
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon>
|
<n-icon>
|
||||||
@@ -488,6 +492,7 @@ onBeforeUnmount(() => {
|
|||||||
style="overflow: auto; max-height: 100vh;">
|
style="overflow: auto; max-height: 100vh;">
|
||||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||||
|
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||||
@@ -499,6 +504,48 @@ onBeforeUnmount(() => {
|
|||||||
</n-card>
|
</n-card>
|
||||||
</template>
|
</template>
|
||||||
</n-split>
|
</n-split>
|
||||||
|
<div v-else class="mail-list-scroll">
|
||||||
|
<n-list hoverable clickable>
|
||||||
|
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||||
|
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
|
||||||
|
<template #prefix v-if="multiActionMode">
|
||||||
|
<n-checkbox v-model:checked="row.checked" />
|
||||||
|
</template>
|
||||||
|
<n-thing class="mail-list-thing">
|
||||||
|
<template #header>
|
||||||
|
<n-ellipsis class="mail-list-title">
|
||||||
|
{{ row.subject }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</template>
|
||||||
|
<template #description>
|
||||||
|
<div class="mail-list-meta">
|
||||||
|
<n-tag type="info">
|
||||||
|
ID: {{ row.id }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag type="info">
|
||||||
|
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag type="info">
|
||||||
|
<n-ellipsis class="mail-list-meta-text">
|
||||||
|
{{ showEMailTo ? "FROM: " + row.source : row.source }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</n-tag>
|
||||||
|
<n-tag v-if="showEMailTo" type="info">
|
||||||
|
<n-ellipsis class="mail-list-meta-text">
|
||||||
|
TO: {{ row.address }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</n-tag>
|
||||||
|
<AiExtractInfo :metadata="row.metadata" compact />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<n-ellipsis v-if="row.text && mailListPreviewLineClampValue > 0"
|
||||||
|
:line-clamp="mailListPreviewLineClampValue" class="mail-list-preview" :tooltip="false">
|
||||||
|
{{ row.text }}
|
||||||
|
</n-ellipsis>
|
||||||
|
</n-thing>
|
||||||
|
</n-list-item>
|
||||||
|
</n-list>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="left" v-else>
|
<div class="left" v-else>
|
||||||
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
|
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
|
||||||
@@ -521,7 +568,8 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||||
|
:class="{ 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }">
|
||||||
<n-thing :title="row.subject">
|
<n-thing :title="row.subject">
|
||||||
<template #description>
|
<template #description>
|
||||||
<n-tag type="info">
|
<n-tag type="info">
|
||||||
@@ -552,6 +600,7 @@ onBeforeUnmount(() => {
|
|||||||
<n-card :bordered="false" embedded style="overflow: auto;">
|
<n-card :bordered="false" embedded style="overflow: auto;">
|
||||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||||
|
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||||
:onSaveToS3="saveToS3Proxy" />
|
:onSaveToS3="saveToS3Proxy" />
|
||||||
</n-card>
|
</n-card>
|
||||||
@@ -608,8 +657,95 @@ onBeforeUnmount(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-list-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 60vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-thing,
|
||||||
|
.mail-list-title,
|
||||||
|
.mail-list-preview {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-thing,
|
||||||
|
.mail-list-preview {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-thing :deep(.n-thing-main),
|
||||||
|
.mail-list-thing :deep(.n-thing-header),
|
||||||
|
.mail-list-thing :deep(.n-thing-header__title),
|
||||||
|
.mail-list-thing :deep(.n-thing-main__description),
|
||||||
|
.mail-list-thing :deep(.n-thing-main__content) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-meta :deep(.n-tag) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-meta-text {
|
||||||
|
max-width: min(240px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-preview {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-scroll :deep(.n-list-item__main) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-unread :deep(.n-thing-header__title) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-unread :deep(.n-thing-header__title)::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2080f0;
|
||||||
|
content: '';
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-handle {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-handle__grip {
|
||||||
|
width: 4px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--n-resize-trigger-color);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-handle:hover .split-handle__grip {
|
||||||
|
background-color: var(--n-resize-trigger-color-hover);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,45 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
|
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound, ImageRound } from '@vicons/material'
|
||||||
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
||||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||||
import { getDownloadEmlUrl } from '../utils/email-parser';
|
import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||||
|
import { blockRemoteContent } from '../utils/remote-content-policy';
|
||||||
import { utcToLocalDate } from '../utils';
|
import { utcToLocalDate } from '../utils';
|
||||||
import { useGlobalState } from '../store';
|
import { useGlobalState } from '../store';
|
||||||
|
|
||||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
|
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
|
||||||
|
|
||||||
const { t } = useI18n({
|
const { t } = useScopedI18n('components.MailContentRenderer')
|
||||||
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({
|
const props = defineProps({
|
||||||
mail: {
|
mail: {
|
||||||
@@ -62,6 +34,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
enableMailReadStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
// 回调函数 props
|
// 回调函数 props
|
||||||
onDelete: {
|
onDelete: {
|
||||||
type: Function,
|
type: Function,
|
||||||
@@ -78,6 +54,10 @@ const props = defineProps({
|
|||||||
onSaveToS3: {
|
onSaveToS3: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => { }
|
default: () => { }
|
||||||
|
},
|
||||||
|
onUpdateMailReadStatus: {
|
||||||
|
type: Function,
|
||||||
|
default: () => { }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,8 +65,29 @@ const showTextMail = ref(preferShowTextMail.value);
|
|||||||
const showAttachments = ref(false);
|
const showAttachments = ref(false);
|
||||||
const curAttachments = ref([]);
|
const curAttachments = ref([]);
|
||||||
const attachmentLoding = ref(false);
|
const attachmentLoding = ref(false);
|
||||||
|
const readStatusUpdating = ref(false);
|
||||||
const showFullscreen = ref(false);
|
const showFullscreen = ref(false);
|
||||||
|
|
||||||
|
// Per-mail consent, deliberately independent of the global setting: it only
|
||||||
|
// ever turns true when the user clicks "load images" for this specific mail,
|
||||||
|
// and resets when a different mail is shown.
|
||||||
|
const showRemoteImages = ref(false);
|
||||||
|
watch(() => props.mail.id, () => {
|
||||||
|
showRemoteImages.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedMail = computed(() => {
|
||||||
|
if (autoLoadRemoteImages.value || showRemoteImages.value) {
|
||||||
|
return { message: props.mail.message, blocked: 0 };
|
||||||
|
}
|
||||||
|
const { html, blocked } = blockRemoteContent(props.mail.message);
|
||||||
|
return { message: html, blocked };
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLoadRemoteImages = () => {
|
||||||
|
showRemoteImages.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
props.onDelete();
|
props.onDelete();
|
||||||
};
|
};
|
||||||
@@ -104,6 +105,15 @@ const handleForward = () => {
|
|||||||
props.onForward();
|
props.onForward();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateMailReadStatus = async () => {
|
||||||
|
readStatusUpdating.value = true;
|
||||||
|
try {
|
||||||
|
await props.onUpdateMailReadStatus();
|
||||||
|
} finally {
|
||||||
|
readStatusUpdating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleSaveToS3 = async (filename, blob) => {
|
const handleSaveToS3 = async (filename, blob) => {
|
||||||
attachmentLoding.value = true;
|
attachmentLoding.value = true;
|
||||||
@@ -146,6 +156,11 @@ const handleSaveToS3 = async (filename, blob) => {
|
|||||||
{{ t('attachments') }}
|
{{ t('attachments') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
|
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" :loading="readStatusUpdating"
|
||||||
|
@click="handleUpdateMailReadStatus">
|
||||||
|
{{ mail.is_unread === 1 ? t('markAsRead') : t('markAsUnread') }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
|
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
|
||||||
:href="getDownloadEmlUrl(mail.raw)">
|
:href="getDownloadEmlUrl(mail.raw)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -178,17 +193,32 @@ const handleSaveToS3 = async (filename, blob) => {
|
|||||||
</template>
|
</template>
|
||||||
{{ t('fullscreen') }}
|
{{ t('fullscreen') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
</n-space>
|
</n-space>
|
||||||
|
|
||||||
|
<!-- 外部资源阻断提示 -->
|
||||||
|
<n-alert v-if="processedMail.blocked" type="warning" :show-icon="false" :bordered="false"
|
||||||
|
class="remote-images-banner">
|
||||||
|
<n-space align="center" justify="space-between">
|
||||||
|
<span>{{ t('remoteImagesBlocked', { count: processedMail.blocked }) }}</span>
|
||||||
|
<n-button size="tiny" tertiary type="warning" @click="handleLoadRemoteImages">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="ImageRound" />
|
||||||
|
</template>
|
||||||
|
{{ t('loadRemoteImages') }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
<!-- AI 提取信息 -->
|
<!-- AI 提取信息 -->
|
||||||
<AiExtractInfo :metadata="mail.metadata" />
|
<AiExtractInfo :metadata="mail.metadata" />
|
||||||
|
|
||||||
<!-- 邮件内容 -->
|
<!-- 邮件内容 -->
|
||||||
<div class="mail-content" :class="{ 'dark-mode': isDark }">
|
<div class="mail-content" :class="{ 'dark-mode': isDark }">
|
||||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
|
||||||
</iframe>
|
</iframe>
|
||||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,9 +227,9 @@ const handleSaveToS3 = async (filename, blob) => {
|
|||||||
<n-drawer-content :title="mail.subject" closable>
|
<n-drawer-content :title="mail.subject" closable>
|
||||||
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
|
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
|
||||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
|
||||||
</iframe>
|
</iframe>
|
||||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||||
</div>
|
</div>
|
||||||
</n-drawer-content>
|
</n-drawer-content>
|
||||||
</n-drawer>
|
</n-drawer>
|
||||||
@@ -244,6 +274,11 @@ const handleSaveToS3 = async (filename, blob) => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Let the banner's inner space fill the alert so the button sits on the right. */
|
||||||
|
.remote-images-banner :deep(.n-space) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.mail-content {
|
.mail-content {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { watch, onMounted, ref, computed } from "vue";
|
import { watch, onMounted, ref, computed } from "vue";
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { useIsMobile } from '../utils/composables'
|
import { useIsMobile } from '../utils/composables'
|
||||||
import { utcToLocalDate } from '../utils';
|
import { utcToLocalDate } from '../utils';
|
||||||
@@ -46,36 +46,7 @@ const multiActionMode = ref(false)
|
|||||||
const showMultiActionDelete = ref(false)
|
const showMultiActionDelete = ref(false)
|
||||||
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
|
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
|
||||||
|
|
||||||
const { t } = useI18n({
|
const { t } = useScopedI18n('components.SendBox')
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
success: 'Success',
|
|
||||||
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',
|
|
||||||
cancelMultiAction: 'Cancel Multi Action',
|
|
||||||
selectAll: 'Select All of This Page',
|
|
||||||
unselectAll: 'Unselect All',
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
success: '成功',
|
|
||||||
refresh: '刷新',
|
|
||||||
showCode: '切换查看元数据',
|
|
||||||
pleaseSelectMail: "请选择一封邮件查看。",
|
|
||||||
emptySent: "发件箱为空",
|
|
||||||
delete: '删除',
|
|
||||||
deleteMailTip: '确定要删除邮件吗?',
|
|
||||||
multiAction: '多选',
|
|
||||||
cancelMultiAction: '取消多选',
|
|
||||||
selectAll: '全选本页',
|
|
||||||
unselectAll: '取消全选',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||||
if (page !== oldPage || pageSize !== oldPageSize) {
|
if (page !== oldPage || pageSize !== oldPageSize) {
|
||||||
@@ -239,8 +210,13 @@ onMounted(async () => {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</div>
|
||||||
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
<n-split direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
|
||||||
:on-update:size="onSpiltSizeChange">
|
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
|
||||||
|
<template #resize-trigger>
|
||||||
|
<div class="split-handle">
|
||||||
|
<div class="split-handle__grip" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template #1>
|
<template #1>
|
||||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
@@ -408,4 +384,23 @@ pre {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.split-handle {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-handle__grip {
|
||||||
|
width: 4px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--n-resize-trigger-color);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-handle:hover .split-handle__grip {
|
||||||
|
background-color: var(--n-resize-trigger-color-hover);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
|
import { getTurnstileLocale } from '../i18n/locale-registry'
|
||||||
|
import { DEFAULT_LOCALE, isSupportedLocale } from '../i18n/utils'
|
||||||
const { openSettings, isDark } = useGlobalState()
|
const { openSettings, isDark } = useGlobalState()
|
||||||
|
|
||||||
const cfToken = defineModel('value')
|
const cfToken = defineModel('value')
|
||||||
|
|
||||||
const { locale, t } = useI18n({
|
const { locale, t } = useScopedI18n('components.Turnstile')
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
refresh: 'Refresh'
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
refresh: '刷新'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
|
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
|
||||||
const cfTurnstileId = ref("")
|
const cfTurnstileId = ref("")
|
||||||
const turnstileLoading = ref(false)
|
const turnstileLoading = ref(false)
|
||||||
|
let turnstileRenderQueue = Promise.resolve()
|
||||||
|
|
||||||
const refresh = () => checkCfTurnstile(true)
|
const refresh = () => rerenderTurnstile()
|
||||||
defineExpose({ refresh })
|
defineExpose({ refresh })
|
||||||
|
|
||||||
|
const rerenderTurnstile = () => {
|
||||||
|
cfToken.value = "";
|
||||||
|
turnstileRenderQueue = turnstileRenderQueue
|
||||||
|
.catch(() => { })
|
||||||
|
.then(() => checkCfTurnstile(true))
|
||||||
|
turnstileRenderQueue.catch(() => { })
|
||||||
|
return turnstileRenderQueue
|
||||||
|
}
|
||||||
|
|
||||||
const checkCfTurnstile = async (remove) => {
|
const checkCfTurnstile = async (remove) => {
|
||||||
if (!openSettings.value.cfTurnstileSiteKey) return;
|
if (!openSettings.value.cfTurnstileSiteKey) return;
|
||||||
turnstileLoading.value = true;
|
turnstileLoading.value = true;
|
||||||
@@ -41,11 +44,15 @@ const checkCfTurnstile = async (remove) => {
|
|||||||
if (remove && cfTurnstileId.value) {
|
if (remove && cfTurnstileId.value) {
|
||||||
window.turnstile.remove(cfTurnstileId.value);
|
window.turnstile.remove(cfTurnstileId.value);
|
||||||
}
|
}
|
||||||
|
// Cloudflare documents sitekey/theme/language as render-time options and
|
||||||
|
// exposes remove()/render() for widget lifecycle updates, so recreate the
|
||||||
|
// widget when any of those inputs change:
|
||||||
|
// https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
|
||||||
cfTurnstileId.value = window.turnstile.render(
|
cfTurnstileId.value = window.turnstile.render(
|
||||||
`#${containerId}`,
|
`#${containerId}`,
|
||||||
{
|
{
|
||||||
sitekey: openSettings.value.cfTurnstileSiteKey,
|
sitekey: openSettings.value.cfTurnstileSiteKey,
|
||||||
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
|
language: getTurnstileLocale(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE),
|
||||||
theme: isDark.value ? 'dark' : 'light',
|
theme: isDark.value ? 'dark' : 'light',
|
||||||
callback: function (token) {
|
callback: function (token) {
|
||||||
cfToken.value = token;
|
cfToken.value = token;
|
||||||
@@ -57,14 +64,7 @@ const checkCfTurnstile = async (remove) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(isDark, async (isDark) => {
|
watch([isDark, locale, () => openSettings.value.cfTurnstileSiteKey], rerenderTurnstile, { immediate: true })
|
||||||
checkCfTurnstile(true)
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
cfToken.value = "";
|
|
||||||
checkCfTurnstile(true);
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -73,7 +73,7 @@ onMounted(() => {
|
|||||||
<n-form-item-row>
|
<n-form-item-row>
|
||||||
<n-flex vertical>
|
<n-flex vertical>
|
||||||
<div :id="containerId"></div>
|
<div :id="containerId"></div>
|
||||||
<n-button text @click="checkCfTurnstile(true)">
|
<n-button text @click="rerenderTurnstile">
|
||||||
{{ t('refresh') }}
|
{{ t('refresh') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, h } from 'vue'
|
import { onMounted, ref, h } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import type { DropdownOption } from 'naive-ui'
|
import type { DropdownOption } from 'naive-ui'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -24,30 +24,7 @@ const props = defineProps({
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const { t } = useI18n({
|
const { t } = useScopedI18n('components.WebhookComponent')
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
successTip: 'Success',
|
|
||||||
test: 'Test',
|
|
||||||
save: 'Save',
|
|
||||||
notEnabled: 'Webhook is not enabled for you',
|
|
||||||
urlMissing: 'URL is required',
|
|
||||||
enable: 'Enable',
|
|
||||||
presets: 'Presets',
|
|
||||||
fillInDemoTip: 'Please modify the URL and other settings to your own',
|
|
||||||
},
|
|
||||||
zh: {
|
|
||||||
successTip: '成功',
|
|
||||||
test: '测试',
|
|
||||||
save: '保存',
|
|
||||||
notEnabled: 'Webhook 未开启,请联系管理员开启',
|
|
||||||
urlMissing: 'URL 不能为空',
|
|
||||||
enable: '启用',
|
|
||||||
presets: '示例模板',
|
|
||||||
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
class WebhookSettings {
|
class WebhookSettings {
|
||||||
enabled: boolean = false
|
enabled: boolean = false
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
type RuntimeConfig = Record<string, unknown>
|
||||||
|
|
||||||
|
const getRuntimeConfig = (): RuntimeConfig => {
|
||||||
|
if (typeof document === 'undefined') return {}
|
||||||
|
|
||||||
|
const content = document.querySelector('#app-config')?.textContent
|
||||||
|
if (!content?.trim()) return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(content)
|
||||||
|
return config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse app config', error)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfig = getRuntimeConfig()
|
||||||
|
|
||||||
|
const getStringConfigValue = (key: string, buildValue: string): string => {
|
||||||
|
const runtimeValue = runtimeConfig[key]
|
||||||
|
return typeof runtimeValue === 'string' ? runtimeValue : buildValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTelegramConfigValue = (buildValue: string): string | boolean => {
|
||||||
|
const runtimeValue = runtimeConfig.IS_TELEGRAM
|
||||||
|
return typeof runtimeValue === 'string' || typeof runtimeValue === 'boolean'
|
||||||
|
? runtimeValue
|
||||||
|
: buildValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_CONFIG = {
|
||||||
|
API_BASE: getStringConfigValue('API_BASE', import.meta.env.VITE_API_BASE || ''),
|
||||||
|
DEFAULT_LANG: getStringConfigValue('DEFAULT_LANG', import.meta.env.VITE_DEFAULT_LANG || ''),
|
||||||
|
CF_WEB_ANALY_TOKEN: getStringConfigValue('CF_WEB_ANALY_TOKEN', import.meta.env.VITE_CF_WEB_ANALY_TOKEN || ''),
|
||||||
|
IS_TELEGRAM: getTelegramConfigValue(import.meta.env.VITE_IS_TELEGRAM || ''),
|
||||||
|
GOOGLE_AD_CLIENT: getStringConfigValue('GOOGLE_AD_CLIENT', import.meta.env.VITE_GOOGLE_AD_CLIENT || ''),
|
||||||
|
GOOGLE_AD_SLOT: getStringConfigValue('GOOGLE_AD_SLOT', import.meta.env.VITE_GOOGLE_AD_SLOT || ''),
|
||||||
|
} as const
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const withNamespace = (namespace: string, key: string) => `${namespace}.${key}`
|
||||||
|
|
||||||
|
export const useScopedI18n = (namespace: string) => {
|
||||||
|
const composer = useI18n({ useScope: 'global' })
|
||||||
|
|
||||||
|
return {
|
||||||
|
...composer,
|
||||||
|
t: ((key: string, ...args: unknown[]) => composer.t(withNamespace(namespace, key), ...(args as []))) as typeof composer.t,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import {
|
||||||
|
FALLBACK_LOCALE,
|
||||||
|
getInitialLocale,
|
||||||
|
} from './utils'
|
||||||
|
import { I18N_MESSAGES } from './messages'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false, // you must set `false`, to use Composition API
|
||||||
|
locale: getInitialLocale(),
|
||||||
|
fallbackLocale: FALLBACK_LOCALE,
|
||||||
|
messages: I18N_MESSAGES,
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
dateDeDE,
|
||||||
|
dateEnUS,
|
||||||
|
dateEsAR,
|
||||||
|
dateJaJP,
|
||||||
|
datePtBR,
|
||||||
|
dateZhCN,
|
||||||
|
deDE,
|
||||||
|
enUS,
|
||||||
|
esAR,
|
||||||
|
jaJP,
|
||||||
|
ptBR,
|
||||||
|
zhCN,
|
||||||
|
} from 'naive-ui'
|
||||||
|
|
||||||
|
import type { NDateLocale, NLocale } from 'naive-ui'
|
||||||
|
|
||||||
|
type NaiveLocaleConfig = {
|
||||||
|
locale: NLocale
|
||||||
|
dateLocale: NDateLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocaleRegistryEntry = {
|
||||||
|
locale: string
|
||||||
|
label: string
|
||||||
|
browserMatches: string[]
|
||||||
|
naive: NaiveLocaleConfig
|
||||||
|
turnstileLocale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LOCALE_REGISTRY = [
|
||||||
|
{
|
||||||
|
locale: 'zh',
|
||||||
|
label: '中文',
|
||||||
|
browserMatches: ['zh'],
|
||||||
|
naive: { locale: zhCN, dateLocale: dateZhCN },
|
||||||
|
turnstileLocale: 'zh-CN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: 'en',
|
||||||
|
label: 'English',
|
||||||
|
browserMatches: ['en'],
|
||||||
|
naive: { locale: enUS, dateLocale: dateEnUS },
|
||||||
|
turnstileLocale: 'en',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: 'es',
|
||||||
|
label: 'Español',
|
||||||
|
browserMatches: ['es'],
|
||||||
|
naive: { locale: esAR, dateLocale: dateEsAR },
|
||||||
|
turnstileLocale: 'es',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: 'pt-BR',
|
||||||
|
label: 'Português (Brasil)',
|
||||||
|
browserMatches: ['pt'],
|
||||||
|
naive: { locale: ptBR, dateLocale: datePtBR },
|
||||||
|
turnstileLocale: 'pt-BR',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: 'ja',
|
||||||
|
label: '日本語',
|
||||||
|
browserMatches: ['ja'],
|
||||||
|
naive: { locale: jaJP, dateLocale: dateJaJP },
|
||||||
|
turnstileLocale: 'ja',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: 'de',
|
||||||
|
label: 'Deutsch',
|
||||||
|
browserMatches: ['de'],
|
||||||
|
naive: { locale: deDE, dateLocale: dateDeDE },
|
||||||
|
turnstileLocale: 'de',
|
||||||
|
},
|
||||||
|
] as const satisfies readonly LocaleRegistryEntry[]
|
||||||
|
|
||||||
|
export type SupportedLocale = (typeof LOCALE_REGISTRY)[number]['locale']
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = LOCALE_REGISTRY.map(({ locale }) => locale) as SupportedLocale[]
|
||||||
|
|
||||||
|
const localeRegistryMap = Object.fromEntries(
|
||||||
|
LOCALE_REGISTRY.map((entry) => [entry.locale, entry]),
|
||||||
|
) as Record<SupportedLocale, (typeof LOCALE_REGISTRY)[number]>
|
||||||
|
|
||||||
|
export const getLocaleRegistryEntry = (locale: SupportedLocale) => {
|
||||||
|
return localeRegistryMap[locale]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLocaleLabel = (locale: SupportedLocale) => {
|
||||||
|
return getLocaleRegistryEntry(locale).label
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLocaleOptions = () => {
|
||||||
|
return LOCALE_REGISTRY.map(({ locale, label }) => ({
|
||||||
|
label,
|
||||||
|
value: locale,
|
||||||
|
key: locale,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getNaiveLocaleConfig = (locale: SupportedLocale) => {
|
||||||
|
return getLocaleRegistryEntry(locale).naive
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTurnstileLocale = (locale: SupportedLocale) => {
|
||||||
|
return getLocaleRegistryEntry(locale).turnstileLocale
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
export const deMessages = {
|
||||||
|
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
|
||||||
|
"views.index.SendMail.composeMail": "E-Mail verfassen",
|
||||||
|
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||||
|
"views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||||
|
"views.index.SendMail.recipientAddress": "Empfängeradresse",
|
||||||
|
"views.index.SendMail.recipientName": "Empfängername (optional)",
|
||||||
|
"views.index.SendMail.senderAddress": "Absenderadresse",
|
||||||
|
"views.index.SendMail.senderName": "Absendername (optional)",
|
||||||
|
"views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
|
||||||
|
"views.admin.SendMail.composeMail": "E-Mail verfassen",
|
||||||
|
"views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||||
|
"views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||||
|
"views.admin.SendMail.recipientAddress": "Empfängeradresse",
|
||||||
|
"views.admin.SendMail.recipientName": "Empfängername (optional)",
|
||||||
|
"views.admin.SendMail.senderAddress": "Absenderadresse",
|
||||||
|
"views.admin.SendMail.senderName": "Absendername (optional)",
|
||||||
|
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
|
||||||
|
"views.admin.DatabaseManager.free_plan": "Free",
|
||||||
|
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||||
|
"views.admin.DatabaseManager.plan": "Workers-Tarif",
|
||||||
|
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare-Workers-Tarif auswählen",
|
||||||
|
"views.admin.DatabaseManager.planSaved": "Workers-Tarif gespeichert",
|
||||||
|
"views.admin.DatabaseManager.single_database_limit": "Datenbank-Kapazitätslimit",
|
||||||
|
"views.admin.DatabaseManager.storage_description": "Vergleiche die aktuelle Datenbankgröße mit den Tariflimits.",
|
||||||
|
"views.admin.DatabaseManager.storage_tip": "Die Auslastung wird anhand der aktuellen Datenbankgröße und des gewählten Tariflimits berechnet.",
|
||||||
|
"views.admin.DatabaseManager.storage_title": "D1-Speicherkapazität",
|
||||||
|
"views.admin.DatabaseManager.storage_usage": "Kapazitätsauslastung",
|
||||||
|
"views.admin.DatabaseManager.unavailable": "Nicht verfügbar",
|
||||||
|
"views.index.SimpleIndex.mailCount": "{current} / {total} E-Mails",
|
||||||
|
"views.admin.Statistics.activeAddressCount30days": "Aktive Adressanzahl in 30 Tagen",
|
||||||
|
"views.admin.Statistics.activeAddressCount7days": "Aktive Adressanzahl in 7 Tagen",
|
||||||
|
"views.Index.about": "Über",
|
||||||
|
"views.Admin.about": "Über",
|
||||||
|
"views.Header.accessHeader": "Zugangspasswort",
|
||||||
|
"views.Header.admin": "Admin-Konsole",
|
||||||
|
"views.Admin.mailbox_management": "Postfachverwaltung",
|
||||||
|
"views.Admin.mailbox_list": "Postfächer",
|
||||||
|
"views.Index.mailboxSettings": "Postfacheinstellungen",
|
||||||
|
"views.Admin.mailbox_settings": "Globale Postfacheinstellungen",
|
||||||
|
"views.index.SimpleIndex.mailboxSettings": "Postfacheinstellungen",
|
||||||
|
"views.index.Attachment.action": "Aktion",
|
||||||
|
"views.admin.SenderAccess.action": "Aktion",
|
||||||
|
"views.user.UserSettings.actions": "Aktionen",
|
||||||
|
"views.user.AddressManagement.actions": "Aktionen",
|
||||||
|
"views.index.TelegramAddress.actions": "Aktionen",
|
||||||
|
"views.admin.Account.actions": "Aktionen",
|
||||||
|
"views.index.LocalAddress.actions": "Aktionen",
|
||||||
|
"views.admin.UserManagement.actions": "Aktionen",
|
||||||
|
"views.admin.AccountSettings.actions": "Aktionen",
|
||||||
|
"views.admin.AccountSettings.add": "Hinzufügen",
|
||||||
|
"views.admin.Maintenance.customSqlTip": "Füge benutzerdefinierte DELETE-SQL-Anweisungen für die geplante Bereinigung hinzu. Pro Eintrag ist nur eine DELETE-Anweisung erlaubt.",
|
||||||
|
"views.admin.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
|
||||||
|
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
|
||||||
|
"components.AddressSelect.selectMailbox": "Postfach auswählen",
|
||||||
|
"views.user.AddressManagement.address": "Adresse",
|
||||||
|
"views.index.TelegramAddress.address": "Adresse",
|
||||||
|
"views.admin.SenderAccess.address": "Adresse",
|
||||||
|
"views.index.LocalAddress.address": "Adresse",
|
||||||
|
"views.admin.CreateAccount.address": "Adresse",
|
||||||
|
"views.admin.AiExtractSettings.allowList": "Adressfreigabeliste (Adresse eingeben und Enter drücken; Wildcards werden unterstützt)",
|
||||||
|
"views.admin.AccountSettings.send_address_block_list": "Blockierbegriffe für ausgehende E-Mails",
|
||||||
|
"views.admin.AccountSettings.address_block_list": "Blockierbegriffe für Benutzer (Administrator kann überspringen)",
|
||||||
|
"views.index.SimpleIndex.addressCopied": "Adresse erfolgreich kopiert",
|
||||||
|
"views.admin.Statistics.addressCount": "Adressanzahl",
|
||||||
|
"views.admin.UserManagement.address_count": "Adressanzahl",
|
||||||
|
"views.User.address_management": "Adressverwaltung",
|
||||||
|
"views.admin.UserManagement.userAddressManagement": "Adressverwaltung",
|
||||||
|
"views.index.AddressBar.addressPassword": "Adresspasswort",
|
||||||
|
"views.admin.CreateAccount.addressPassword": "Adresspasswort",
|
||||||
|
"views.Admin.adminAccount": "Administrator",
|
||||||
|
"views.Admin.accessHeader": "Admin-Passwort",
|
||||||
|
"views.Admin.loginViaPassword": "Administrator mit Passwort anmelden",
|
||||||
|
"views.admin.AiExtractSettings.title": "KI-E-Mail-Extraktionseinstellungen",
|
||||||
|
"views.Admin.aiExtractSettings": "KI-Extraktionseinstellungen",
|
||||||
|
"views.admin.AccountSettings.match_all": "Alle",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match": "Suffix-Abgleich für Subdomains beim Erstellen von Adressen erlauben",
|
||||||
|
"views.admin.UserSettings.enableUserRegister": "Benutzerregistrierung erlauben",
|
||||||
|
"views.admin.AccountSettings.match_any": "Beliebig",
|
||||||
|
"views.Index.appearance": "Darstellung",
|
||||||
|
"views.Admin.appearance": "Darstellung",
|
||||||
|
"views.admin.IpBlacklistSettings.tip_scope": "Gilt für: Adresse erstellen, E-Mail senden, externe Sende-API, Benutzerregistrierung und Code-Prüfung",
|
||||||
|
"views.index.AccountSettings.clearInboxConfirm": "Möchtest du wirklich alle E-Mails im Posteingang löschen?",
|
||||||
|
"views.index.AccountSettings.clearSentItemsConfirm": "Möchtest du wirklich alle E-Mails in den gesendeten Elementen löschen?",
|
||||||
|
"views.admin.Account.multiClearInboxTip": "Möchtest du wirklich den Posteingang der ausgewählten Adressen leeren?",
|
||||||
|
"views.admin.Account.clearInboxTip": "Möchtest du wirklich den Posteingang dieser E-Mail-Adresse leeren?",
|
||||||
|
"views.admin.Account.multiClearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente der ausgewählten Adressen leeren?",
|
||||||
|
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?",
|
||||||
|
"views.admin.Account.multiDeleteTip": "Möchtest du wirklich die ausgewählten Adressen löschen?",
|
||||||
|
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
|
||||||
|
"views.admin.Account.deleteTip": "Möchtest du wirklich dieses Postfach löschen?",
|
||||||
|
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
|
||||||
|
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen? Dein Benutzerkonto wird dabei nicht gelöscht.",
|
||||||
|
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
|
||||||
|
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||||
|
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||||
|
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||||
|
"views.admin.AccountSettings.delete_rule_confirm": "Möchtest du diese Regel wirklich löschen?",
|
||||||
|
"views.admin.UserManagement.deleteUserTip": "Möchtest du diesen Benutzer wirklich löschen?",
|
||||||
|
"views.Admin.logoutConfirmContent": "Möchtest du dich wirklich aus dem Admin-Bereich abmelden?",
|
||||||
|
"views.user.UserSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
|
||||||
|
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN-Organisationssperrliste",
|
||||||
|
"views.admin.IpBlacklistSettings.tip_asn": "ASN-Organisation: Nach ISP/Provider blockieren. Groß-/Kleinschreibung-unabhängiger Textabgleich oder Regex.",
|
||||||
|
"components.AiExtractInfo.authLink": "Authentifizierungslink",
|
||||||
|
"views.admin.Maintenance.autoCleanup": "Automatische Bereinigung",
|
||||||
|
"components.MailBox.autoRefresh": "Automatische Aktualisierung",
|
||||||
|
"components.MailBox.backToList": "Zurück zur Liste",
|
||||||
|
"views.common.Appearance.autoRefreshInterval": "Automatisches Aktualisierungsintervall (s)",
|
||||||
|
"views.Index.auto_reply": "Automatische Antwort",
|
||||||
|
"views.index.AutoReply.autoReply": "Automatische Antwort",
|
||||||
|
"views.common.Login.autoGeneratedName": "Automatisch erzeugter Name",
|
||||||
|
"views.admin.SenderAccess.balance": "Guthaben",
|
||||||
|
"views.admin.Maintenance.basicCleanup": "Grundbereinigung",
|
||||||
|
"views.user.AddressManagement.unbindAddressTip": "Wechsle vor dem Trennen zu dieser E-Mail-Adresse und speichere die Zugangsdaten.",
|
||||||
|
"views.index.TelegramAddress.bind": "Verknüpfen",
|
||||||
|
"views.index.TelegramAddress.bindAddressSuccess": "Adresse erfolgreich verknüpft",
|
||||||
|
"views.index.LocalAddress.bindAddressSuccess": "Adresse erfolgreich verknüpft",
|
||||||
|
"views.User.bind_address": "Mailadresse verknüpfen",
|
||||||
|
"views.admin.IpBlacklistSettings.enable_tip": "IPs blockieren, die Sperrlistenmustern entsprechen und auf limitierte APIs zugreifen",
|
||||||
|
"views.admin.AccountSettings.fromBlockList": "Blockierbegriffe für eingehende E-Mails",
|
||||||
|
"views.admin.AccountSettings.block_receive_unknow_address_email": "Empfang von E-Mails an unbekannte Adressen blockieren",
|
||||||
|
"views.common.Appearance.bottom": "unten",
|
||||||
|
"views.admin.IpBlacklistSettings.fingerprint_blacklist": "Browser-Fingerprint-Sperrliste",
|
||||||
|
"views.admin.IpBlacklistSettings.tip_fingerprint": "Browser-Fingerprint: Sperrt per Fingerprint. Unterstützt exakte Treffer oder Regex.",
|
||||||
|
"views.admin.AccountSettings.cancel": "Abbrechen",
|
||||||
|
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||||
|
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||||
|
"views.user.AddressManagement.openMailbox": "Postfach öffnen",
|
||||||
|
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
|
||||||
|
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
|
||||||
|
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
|
||||||
|
"views.admin.UserManagement.changeRole": "Ändern Rolle",
|
||||||
|
"components.SendBox.showCode": "Ansicht des Originalcodes umschalten",
|
||||||
|
"views.admin.Telegram.status": "Status prüfen",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match_follow_env_note": "Wenn du „Umgebungsvariable folgen“ auswählst, wird die Admin-Überschreibung entfernt und der Zustand auf „nicht gesetzt“ zurückgesetzt. Das tatsächliche Ergebnis wird weiterhin durch die Worker-Umgebung und die Prioritätsregeln bestimmt.",
|
||||||
|
"views.admin.Maintenance.cleanupNow": "Jetzt bereinigen",
|
||||||
|
"views.admin.Maintenance.cleanupSuccess": "Bereinigung erfolgreich",
|
||||||
|
"views.admin.Maintenance.addressCreateLabel": "Adressen löschen, die vor mehr als n Tagen erstellt wurden",
|
||||||
|
"views.admin.Maintenance.emptyAddressLabel": "Leere Adressen löschen, die älter als n Tage sind",
|
||||||
|
"views.admin.Maintenance.inactiveAddressLabel": "Inaktive Adressen löschen, die älter als n Tage sind",
|
||||||
|
"views.admin.Maintenance.mailBoxLabel": "Posteingänge löschen, die älter als n Tage sind",
|
||||||
|
"views.admin.Maintenance.sendBoxLabel": "Postausgänge löschen, die älter als n Tage sind",
|
||||||
|
"views.admin.Maintenance.unboundAddressLabel": "Nicht verknüpfte Adressen löschen, die älter als n Tage sind",
|
||||||
|
"views.admin.Maintenance.mailUnknowLabel": "E-Mails mit unbekanntem Empfänger löschen, die älter als n Tage sind",
|
||||||
|
"views.index.AccountSettings.clearInbox": "Posteingang leeren",
|
||||||
|
"views.admin.Account.clearInbox": "Posteingang leeren",
|
||||||
|
"views.index.AccountSettings.clearSentItems": "Gesendete Elemente leeren",
|
||||||
|
"views.admin.Account.clearSentItems": "Gesendete Elemente leeren",
|
||||||
|
"views.Header.title": "Cloudflare Temporäre E-Mail",
|
||||||
|
"views.admin.DatabaseManager.code_db_version": "Vom Code benötigte DB-Version",
|
||||||
|
"views.user.UserOauth2Callback.codeNotFound": "Code nicht gefunden",
|
||||||
|
"views.admin.AccountSettings.config": "Konfiguration",
|
||||||
|
"views.admin.RoleAddressConfig.roleConfigDesc": "Lege die maximale Anzahl von Adressen pro Benutzerrolle fest. Rollenbasierte Limits haben Vorrang vor globalen Einstellungen. Verwende 0 für unbegrenzt.",
|
||||||
|
"views.Admin.confirm": "Bestätigen",
|
||||||
|
"views.Admin.logoutConfirmTitle": "Abmeldung bestätigen",
|
||||||
|
"views.index.AccountSettings.confirmPassword": "Passwort bestätigen",
|
||||||
|
"views.index.SendMail.content": "Inhalt",
|
||||||
|
"views.admin.SendMail.content": "Inhalt",
|
||||||
|
"views.index.SendMail.contentEmpty": "Der Inhalt ist leer",
|
||||||
|
"views.admin.SendMail.contentEmpty": "Der Inhalt ist leer",
|
||||||
|
"components.AddressSelect.copied": "Kopiert",
|
||||||
|
"components.AiExtractInfo.copySuccess": "Erfolgreich kopiert",
|
||||||
|
"components.AddressSelect.copy": "Kopieren",
|
||||||
|
"views.index.SimpleIndex.copyAddress": "Kopieren",
|
||||||
|
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
|
||||||
|
"views.Footer.copyright": "Urheberrecht",
|
||||||
|
"views.Admin.mailbox_create": "E-Mail-Adresse erstellen",
|
||||||
|
"views.admin.CreateAccount.createEmailAddress": "E-Mail-Adresse erstellen",
|
||||||
|
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
|
||||||
|
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
|
||||||
|
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
|
||||||
|
"views.user.UserSettings.createPasskey": "Passkey erstellen",
|
||||||
|
"views.admin.UserManagement.createUser": "Benutzer erstellen",
|
||||||
|
"views.user.UserSettings.created_at": "Erstellt am",
|
||||||
|
"views.admin.Account.created_at": "Erstellt am",
|
||||||
|
"views.admin.SenderAccess.created_at": "Erstellt am",
|
||||||
|
"views.admin.UserManagement.created_at": "Erstellt am",
|
||||||
|
"views.common.Login.credentialLogin": "Mit Zugangsdaten anmelden",
|
||||||
|
"views.admin.DatabaseManager.current_db_version": "Aktuelle DB-Version",
|
||||||
|
"views.user.UserBar.currentUser": "Aktuell angemeldeter Benutzer",
|
||||||
|
"views.admin.UserManagement.roleDonotExist": "Die aktuelle Rolle existiert nicht",
|
||||||
|
"views.admin.Maintenance.customSqlCleanup": "Benutzerdefinierte SQL-Bereinigung",
|
||||||
|
"views.admin.AccountSettings.send_mail_daily_limit": "Tageslimit",
|
||||||
|
"views.admin.AccountSettings.send_mail_daily_limit_invalid": "Das Tageslimit muss eine ganze Zahl größer oder gleich -1 sein",
|
||||||
|
"views.admin.IpBlacklistSettings.tip_daily_limit": "Tageslimit: Begrenzt die maximale Anzahl von Anfragen pro IP-Adresse und Tag (1-1000000).",
|
||||||
|
"views.admin.IpBlacklistSettings.daily_request_limit": "Tägliches Anfrage-Limit",
|
||||||
|
"views.Header.dark": "Dunkel",
|
||||||
|
"views.Admin.database": "Datenbank",
|
||||||
|
"views.admin.DatabaseManager.need_initialization_tip": "Die Datenbank muss initialisiert werden. Bitte zuerst initialisieren.",
|
||||||
|
"views.admin.DatabaseManager.initializationSuccess": "Datenbank erfolgreich initialisiert",
|
||||||
|
"views.admin.DatabaseManager.migrationSuccess": "Datenbank erfolgreich migriert",
|
||||||
|
"views.admin.DatabaseManager.need_migration_tip": "Die Datenbank muss migriert werden. Bitte zuerst migrieren.",
|
||||||
|
"components.MailBox.delete": "Löschen",
|
||||||
|
"components.MailContentRenderer.delete": "Löschen",
|
||||||
|
"components.SendBox.delete": "Löschen",
|
||||||
|
"views.index.Attachment.delete": "Löschen",
|
||||||
|
"views.admin.Account.delete": "Löschen",
|
||||||
|
"views.admin.UserOauth2Settings.delete": "Löschen",
|
||||||
|
"views.admin.SenderAccess.delete": "Löschen",
|
||||||
|
"views.admin.UserManagement.delete": "Löschen",
|
||||||
|
"views.admin.AccountSettings.delete_rule": "Löschen",
|
||||||
|
"views.admin.Maintenance.deleteCustomSql": "Löschen",
|
||||||
|
"views.index.AccountSettings.deleteMailbox": "Postfach löschen",
|
||||||
|
"views.admin.Account.deleteMailbox": "Postfach löschen",
|
||||||
|
"views.user.UserSettings.deletePasskey": "Passkey löschen",
|
||||||
|
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
|
||||||
|
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
|
||||||
|
"views.index.Attachment.deleteSuccess": "Erfolgreich gelöscht",
|
||||||
|
"views.admin.SenderAccess.disable": "Deaktivieren",
|
||||||
|
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
|
||||||
|
"views.common.Appearance.preferShowTextMail": "Text-Mail standardmäßig anzeigen",
|
||||||
|
"views.admin.AccountSettings.domain_list": "Domain-Liste (optional)",
|
||||||
|
"views.admin.AccountSettings.source_patterns_tip": "Die Domain-Liste filtert nach Empfängeradresse, die Quell-Regex nach Absenderadresse. Für die Weiterleitung müssen beide Bedingungen erfüllt sein (UND-Logik). Leer lassen, um den jeweiligen Filter zu überspringen.",
|
||||||
|
"views.admin.UserManagement.domains": "Domains",
|
||||||
|
"views.index.Attachment.download": "Herunterladen",
|
||||||
|
"components.MailBox.downloadMail": "Herunterladen Mail",
|
||||||
|
"components.MailContentRenderer.downloadMail": "Herunterladen Mail",
|
||||||
|
"views.admin.Maintenance.sqlNamePlaceholder": "z. B. Alte Logs bereinigen",
|
||||||
|
"views.admin.Maintenance.sqlPlaceholder": "z. B. DELETE FROM raw_mails WHERE source GLOB '*{'@'}example.com' AND created_at < datetime('now', '-3 day')",
|
||||||
|
"views.admin.AccountSettings.source_patterns_placeholder": "z. B. gmail.com",
|
||||||
|
"views.admin.AccountSettings.forwarding_rule_warning": "Jede Regel wird unabhängig ausgeführt. Die Weiterleitungsadresse muss verifiziert sein.",
|
||||||
|
"views.index.SendMail.edit": "Bearbeiten",
|
||||||
|
"views.admin.SendMail.edit": "Bearbeiten",
|
||||||
|
"views.user.UserLogin.email": "E-Mail",
|
||||||
|
"views.admin.UserManagement.email": "E-Mail",
|
||||||
|
"views.common.Login.email": "E-Mail",
|
||||||
|
"views.common.Login.credential": "E-Mail-Adresszugangsdaten",
|
||||||
|
"views.common.Login.emailPasswordRequired": "E-Mail und Passwort sind erforderlich",
|
||||||
|
"views.admin.UserSettings.emailCheckRegex": "Regex zur E-Mail-Prüfung (z. B. ^[^.]+{'@'}.+$, um Punkte vor {'@'} zu verbieten)",
|
||||||
|
"views.admin.AccountSettings.email_forwarding_config": "E-Mail-Weiterleitungskonfiguration",
|
||||||
|
"views.admin.UserOauth2Settings.userEmailFormat": "E-Mail-Regex-Muster",
|
||||||
|
"views.Admin.mails": "E-Mails",
|
||||||
|
"views.index.AutoReply.sourcePrefixPlaceholder": "Leer=alle, Präfixabgleich oder /regex/",
|
||||||
|
"components.WebhookComponent.enable": "Aktivieren",
|
||||||
|
"views.admin.UserOauth2Settings.enable": "Aktivieren",
|
||||||
|
"views.admin.SenderAccess.enable": "Aktivieren",
|
||||||
|
"views.admin.Telegram.enable": "Aktivieren",
|
||||||
|
"views.admin.UserSettings.enable": "Aktivieren",
|
||||||
|
"views.admin.AiExtractSettings.enableAllowList": "Aktivieren Adressfreigabeliste",
|
||||||
|
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte Benutzer beschränken)",
|
||||||
|
"views.index.AutoReply.enableAutoReply": "Automatische Antwort aktivieren",
|
||||||
|
"views.admin.Maintenance.cronTip": "Um die Cron-Bereinigung zu aktivieren, konfiguriere [crons] im Worker. Siehe Dokumentation; 0 Tage bedeutet alles löschen.",
|
||||||
|
"views.admin.IpBlacklistSettings.enable_daily_limit": "Aktivieren Tägliches Anfrage-Limit",
|
||||||
|
"views.admin.UserSettings.enableEmailCheckRegex": "E-Mail-Prüfregex aktivieren",
|
||||||
|
"views.admin.UserOauth2Settings.enableEmailFormat": "E-Mail-Format aktivieren",
|
||||||
|
"views.admin.Telegram.enableGlobalMailPush": "Globalen Mail-Push aktivieren (Telegram-Chat-ID manuell eingeben)",
|
||||||
|
"views.admin.IpBlacklistSettings.enable_ip_blacklist": "IP-Sperrliste aktivieren",
|
||||||
|
"views.admin.IpBlacklistSettings.enable_ip_whitelist": "IP-Freigabeliste aktivieren (streng)",
|
||||||
|
"views.admin.UserOauth2Settings.enableMailAllowList": "Aktivieren E-Mail-Adressfreigabeliste(Manually enterable)",
|
||||||
|
"views.admin.UserSettings.enableMailAllowList": "Aktivieren E-Mail-Adressfreigabeliste(Manually enterable)",
|
||||||
|
"views.admin.UserSettings.enableMailVerify": "Mail-Prüfung aktivieren (die Absenderadresse muss im System vorhanden sein, Guthaben haben und normal senden können)",
|
||||||
|
"views.admin.Telegram.enableTelegramAllowList": "Aktivieren Telegram-Freigabeliste(Manually input Chat ID)",
|
||||||
|
"views.admin.IpBlacklistSettings.asn_blacklist_placeholder": "ASN-Organisation eingeben (z. B. Google, Amazon)",
|
||||||
|
"views.admin.IpBlacklistSettings.fingerprint_blacklist_placeholder": "Fingerprint-ID eingeben (z. B. a1b2c3d4e5f6g7h8)",
|
||||||
|
"views.admin.IpBlacklistSettings.daily_request_limit_placeholder": "Limit eingeben (z. B. 1000)",
|
||||||
|
"views.admin.IpBlacklistSettings.ip_blacklist_placeholder": "Muster eingeben (z. B. 192.168.1 oder ^10\\.0\\.0\\.5$)",
|
||||||
|
"views.common.Login.bindUserAddressError": "Fehler beim Verknüpfen der E-Mail-Adresse mit dem Benutzer",
|
||||||
|
"views.admin.IpBlacklistSettings.ip_whitelist_placeholder": "Exakte IP (z. B. 1.2.3.4) oder verankerte Regex (z. B. ^192\\.168\\.1\\.\\d+$)",
|
||||||
|
"views.index.SimpleIndex.exitSimpleIndex": "Einfachen Modus verlassen",
|
||||||
|
"components.MailBox.keywordQueryTip": "Aktuelle Seite filtern",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match_follow_env": "Umgebungsvariable folgen",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match_force_disable": "Erzwingt deaktivieren",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match_force_enable": "Erzwingt aktivieren",
|
||||||
|
"views.user.UserLogin.forgotPassword": "Passwort vergessen",
|
||||||
|
"components.MailBox.forwardMail": "Weiterleiten",
|
||||||
|
"components.MailContentRenderer.forward": "Weiterleiten",
|
||||||
|
"views.admin.AccountSettings.forward_address": "Weiterleitungsadresse",
|
||||||
|
"views.admin.AccountSettings.forward_address_required": "Eine Weiterleitungsadresse ist erforderlich",
|
||||||
|
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
|
||||||
|
"components.MailContentRenderer.fullscreen": "Vollbild",
|
||||||
|
"views.common.Login.generateName": "Zufälligen Namen erzeugen",
|
||||||
|
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
|
||||||
|
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
|
||||||
|
"views.common.Login.help": "Hilfe",
|
||||||
|
"views.Header.mailbox": "Postfach",
|
||||||
|
"views.index.SendMail.html": "HTML",
|
||||||
|
"views.admin.SendMail.html": "HTML",
|
||||||
|
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
|
||||||
|
"views.admin.CreateAccount.enablePrefix": "Wenn Präfix aktiviert ist",
|
||||||
|
"views.common.AdminContact.adminContact": "Wenn du Hilfe brauchst, kontaktiere bitte den Administrator ({msg})",
|
||||||
|
"views.admin.Telegram.init": "Initialisieren",
|
||||||
|
"views.admin.DatabaseManager.init": "Datenbank initialisieren",
|
||||||
|
"views.admin.AccountSettings.regex_invalid": "Ungültiges Regex-Muster",
|
||||||
|
"views.Admin.ipBlacklistSettings": "IP-Sperrliste",
|
||||||
|
"views.admin.IpBlacklistSettings.ip_blacklist": "Muster der IP-Sperrliste",
|
||||||
|
"views.admin.IpBlacklistSettings.title": "Einstellungen der IP-Sperrliste",
|
||||||
|
"views.admin.IpBlacklistSettings.tip_ip": "IP-Sperrliste: Unterstützt Textabgleich (z. B. \"192.168.1\") oder Regex (z. B. \"^10\\.0\\.0\\.5$\").",
|
||||||
|
"views.admin.IpBlacklistSettings.whitelist_empty_warning": "Die IP-Freigabeliste ist aktiviert, aber leer. Der Server ignoriert dies, um eine Aussperrung zu vermeiden. Bitte vor dem Aktivieren mindestens einen Eintrag hinzufügen.",
|
||||||
|
"views.admin.IpBlacklistSettings.ip_whitelist": "IP-Freigabeliste Muster",
|
||||||
|
"views.admin.IpBlacklistSettings.tip_whitelist": "IP-Freigabeliste: Strenger Freigabemodus. Einfache Einträge müssen EXAKT zur IP passen (keine Teilzeichenfolge). Verwende verankerte Regex (^...$) für Bereiche. Freigegebene IPs überspringen die Sperrlistenprüfung.",
|
||||||
|
"views.admin.SenderAccess.is_enabled": "Aktiviert",
|
||||||
|
"views.admin.Account.itemCount": "itemCount",
|
||||||
|
"views.admin.SenderAccess.itemCount": "itemCount",
|
||||||
|
"views.admin.UserManagement.itemCount": "itemCount",
|
||||||
|
"views.user.UserMailBox.addressQueryTip": "Leer lassen, um alle Adressen abzufragen",
|
||||||
|
"views.admin.Account.addressQueryTip": "Leer lassen, um alle Adressen abzufragen",
|
||||||
|
"views.admin.Mails.addressQueryTip": "Leer lassen, um alle Adressen abzufragen",
|
||||||
|
"views.common.Appearance.left": "links",
|
||||||
|
"views.common.Login.getNewEmailTip2": "Wenn es leer bleibt, wird eine zufällige E-Mail-Adresse erzeugt.",
|
||||||
|
"views.Header.light": "Hell",
|
||||||
|
"views.admin.IpBlacklistSettings.enable_daily_limit_tip": "Anzahl der API-Anfragen pro IP-Adresse und Tag begrenzen",
|
||||||
|
"components.AddressSelect.localAddresses": "Lokale Adressen",
|
||||||
|
"views.common.Login.bindUserInfo": "Angemeldete Benutzer: Anmeldung ohne verknüpfte E-Mail oder das Erstellen einer neuen Adresse verknüpft sie mit dem aktuellen Benutzer",
|
||||||
|
"views.user.UserOauth2Callback.logging": "Anmeldung läuft...",
|
||||||
|
"views.user.UserLogin.login": "Anmelden",
|
||||||
|
"views.common.Login.login": "Anmelden",
|
||||||
|
"views.common.Login.loginAndBind": "Anmelden und verknüpfen",
|
||||||
|
"views.Admin.loginMethod": "Anmeldemethode",
|
||||||
|
"views.user.UserBar.fetchUserSettingsError": "Das Anmeldepasswort ist ungültig oder das Konto existiert nicht. Es könnte auch ein Netzwerkproblem vorliegen. Bitte später erneut versuchen.",
|
||||||
|
"views.user.UserLogin.loginWith": "Mit {provider} anmelden",
|
||||||
|
"views.user.UserLogin.loginWithPasskey": "Mit Passkey anmelden",
|
||||||
|
"views.user.UserSettings.logout": "Abmelden",
|
||||||
|
"views.user.BindAddress.logout": "Abmelden",
|
||||||
|
"views.Admin.logout": "Abmelden",
|
||||||
|
"views.index.AccountSettings.logout": "Von aktueller E-Mail-Adresse abmelden",
|
||||||
|
"views.Admin.logoutSuccess": "Erfolgreich abgemeldet",
|
||||||
|
"views.admin.UserOauth2Settings.mailAllowList": "E-Mail-Adressfreigabeliste",
|
||||||
|
"views.admin.UserSettings.mailAllowList": "E-Mail-Adressfreigabeliste",
|
||||||
|
"views.index.SimpleIndex.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||||
|
"views.index.AddressBar.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||||
|
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||||
|
"views.admin.CreateAccount.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||||
|
"views.index.AddressBar.fetchAddressError": "Die E-Mail-Zugangsdaten sind ungültig oder das Konto existiert nicht. Es kann auch ein Netzwerkproblem sein. Bitte später erneut versuchen.",
|
||||||
|
"views.Index.inbox": "Posteingang",
|
||||||
|
"views.User.user_mail_box_tab": "Posteingang",
|
||||||
|
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
|
||||||
|
"views.admin.Account.mail_count": "E-Mail-Anzahl",
|
||||||
|
"views.admin.Statistics.mailCount": "E-Mail-Anzahl",
|
||||||
|
"views.admin.UserAddressManagement.mail_count": "E-Mail-Anzahl",
|
||||||
|
"views.index.SimpleIndex.deleteSuccess": "E-Mail erfolgreich gelöscht",
|
||||||
|
"views.user.UserLogin.cannotForgotPassword": "E-Mail-Verifizierung oder Registrierung ist deaktiviert; das Passwort kann nicht zurückgesetzt werden. Bitte den Administrator kontaktieren.",
|
||||||
|
"views.Admin.mailWebhook": "Mail-Webhook",
|
||||||
|
"views.common.Appearance.mailboxSplitSize": "Breite der linken Liste in der zweispaltigen Postfachansicht",
|
||||||
|
"views.common.Appearance.mailListView": "Postfach-Listenansicht in voller Breite",
|
||||||
|
"views.common.Appearance.mailListPreviewLineClamp": "Zeilen der Textvorschau",
|
||||||
|
"views.common.Appearance.off": "Aus",
|
||||||
|
"views.index.SimpleIndex.refreshSuccess": "E-Mails erfolgreich aktualisiert",
|
||||||
|
"views.Admin.unknow": "E-Mails mit unbekanntem Empfänger",
|
||||||
|
"views.Admin.maintenance": "Wartung",
|
||||||
|
"views.index.AddressBar.addressManage": "Verwalten",
|
||||||
|
"views.admin.AccountSettings.source_match_mode": "Abgleichsmodus",
|
||||||
|
"views.admin.RoleAddressConfig.maxAddressCount": "Maximale Adressanzahl (0 = unbegrenzt)",
|
||||||
|
"views.admin.UserSettings.maxAddressCount": "Maximale Anzahl verknüpfbarer E-Mail-Adressen (0 = unbegrenzt)",
|
||||||
|
"views.Header.menu": "Menü",
|
||||||
|
"views.admin.DatabaseManager.migration": "Datenbank migrieren",
|
||||||
|
"views.admin.SenderAccess.modify": "Ändern",
|
||||||
|
"views.admin.AccountSettings.send_mail_monthly_limit": "Monatslimit",
|
||||||
|
"views.admin.AccountSettings.send_mail_monthly_limit_invalid": "Das Monatslimit muss eine ganze Zahl größer oder gleich -1 sein",
|
||||||
|
"components.MailBox.multiAction": "Mehrfachaktion",
|
||||||
|
"components.SendBox.multiAction": "Mehrfachaktion",
|
||||||
|
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
|
||||||
|
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
|
||||||
|
"views.admin.Account.multiDelete": "Mehrfach löschen",
|
||||||
|
"views.user.AddressManagement.emailAddress": "E-Mail-Adresse",
|
||||||
|
"views.index.AutoReply.name": "Name",
|
||||||
|
"views.admin.Account.emailAddress": "E-Mail-Adresse",
|
||||||
|
"views.admin.UserOauth2Settings.name": "Name",
|
||||||
|
"views.admin.UserAddressManagement.emailAddress": "E-Mail-Adresse",
|
||||||
|
"views.admin.Maintenance.sqlName": "Name",
|
||||||
|
"views.index.AccountSettings.newPassword": "Neues Passwort",
|
||||||
|
"views.admin.Account.newPassword": "Neues Passwort",
|
||||||
|
"components.MailBox.nextMail": "Weiter",
|
||||||
|
"views.index.SimpleIndex.nextPage": "Weiter",
|
||||||
|
"views.admin.AccountSettings.noLimitSendAddressList": "Adressliste ohne Guthabenlimit",
|
||||||
|
"views.index.SimpleIndex.noMails": "Keine E-Mails gefunden",
|
||||||
|
"views.admin.RoleAddressConfig.noRolesAvailable": "In der Systemkonfiguration sind keine Rollen verfügbar",
|
||||||
|
"views.index.SendMail.requestAccessTip": "Sendeberechtigung und Guthaben werden für jede E-Mail-Adresse separat verwaltet. Die aktuelle Adresse {address} hat kein verfügbares Guthaben. Fordere die Berechtigung für diese Adresse an oder kontaktiere den Administrator.",
|
||||||
|
"views.index.SendMail.requestSuccess": "Sendeberechtigung für die aktuelle Adresse angefordert",
|
||||||
|
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
|
||||||
|
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
|
||||||
|
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
|
||||||
|
"views.admin.UserOauth2Settings.oauth2Type": "OAuth2-Typ",
|
||||||
|
"views.Header.ok": "OK",
|
||||||
|
"views.Admin.ok": "OK",
|
||||||
|
"views.index.AddressBar.ok": "OK",
|
||||||
|
"views.admin.SenderAccess.ok": "OK",
|
||||||
|
"views.common.Login.ok": "OK",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match_tip": "Wirkt sich nur auf die Domainvalidierung von /api/new_address und /admin/new_address aus. Beispiel: Wenn aktiviert, kann foo.example.com der Basisdomain example.com entsprechen.",
|
||||||
|
"components.AiExtractInfo.open": "Öffnen",
|
||||||
|
"views.index.AddressBar.linkWithAddressCredential": "Automatischen E-Mail-Anmeldelink öffnen",
|
||||||
|
"views.admin.CreateAccount.linkWithAddressCredential": "Automatischen E-Mail-Anmeldelink öffnen",
|
||||||
|
"views.index.SendMail.options": "Optionen",
|
||||||
|
"views.admin.SendMail.options": "Optionen",
|
||||||
|
"components.AiExtractInfo.otherLink": "Weiterer Link",
|
||||||
|
"views.user.UserSettings.passkeyCreated": "Passkey erfolgreich erstellt",
|
||||||
|
"views.user.UserSettings.passkey_name": "Passkey-Name",
|
||||||
|
"views.user.UserLogin.password": "Passwort",
|
||||||
|
"views.admin.UserManagement.password": "Passwort",
|
||||||
|
"views.common.Login.password": "Passwort",
|
||||||
|
"views.index.AccountSettings.passwordChanged": "Passwort erfolgreich geändert",
|
||||||
|
"views.common.Login.passwordLogin": "Passwortanmeldung",
|
||||||
|
"views.admin.Account.passwordResetSuccess": "Passwort erfolgreich zurückgesetzt",
|
||||||
|
"views.index.AccountSettings.passwordMismatch": "Passwörter stimmen nicht überein",
|
||||||
|
"views.index.SendMail.successSend": "Bitte den Postausgang prüfen. Falls es fehlschlägt, Guthaben prüfen oder später erneut versuchen.",
|
||||||
|
"views.admin.SendMail.successSend": "Bitte prüfe deinen Postausgang. Falls es fehlgeschlagen ist, versuche es später erneut.",
|
||||||
|
"views.user.UserLogin.pleaseCompleteTurnstile": "Bitte Turnstile abschließen",
|
||||||
|
"views.index.SimpleIndex.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie zum Anmelden verwenden.",
|
||||||
|
"views.index.AddressBar.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie verwenden, um dich bei deinem E-Mail-Konto anzumelden.",
|
||||||
|
"views.admin.Account.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie verwenden, um dich bei deinem E-Mail-Konto anzumelden.",
|
||||||
|
"views.admin.CreateAccount.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie verwenden, um dich bei deinem E-Mail-Konto anzumelden.",
|
||||||
|
"views.Admin.accessTip": "Bitte das Admin-Passwort eingeben",
|
||||||
|
"views.Header.accessTip": "Bitte das korrekte Zugangspasswort eingeben",
|
||||||
|
"views.admin.AccountSettings.address_block_list_placeholder": "Bitte die zu blockierenden Schlüsselwörter eingeben",
|
||||||
|
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
|
||||||
|
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
|
||||||
|
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
|
||||||
|
"views.admin.CreateAccount.generateName": "Zufälligen Namen erzeugen",
|
||||||
|
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
|
||||||
|
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
|
||||||
|
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
|
||||||
|
"views.user.UserLogin.pleaseInputEmail": "Bitte die E-Mail eingeben",
|
||||||
|
"views.user.UserLogin.pleaseInput": "Bitte E-Mail und Passwort eingeben",
|
||||||
|
"views.admin.Maintenance.tip": "Bitte die Tage eingeben",
|
||||||
|
"views.common.Login.getNewEmailTip1": "Bitte die gewünschte E-Mail-Adresse eingeben. Erlaubt sind nur:",
|
||||||
|
"views.common.Login.credentialInput": "Bitte die Zugangsdaten der Mailadresse eingeben",
|
||||||
|
"views.admin.SenderAccess.modalTip": "Bitte das Sendeguthaben eingeben",
|
||||||
|
"views.user.UserLogin.pleaseLogin": "Bitte anmelden",
|
||||||
|
"views.common.Login.pleaseGetNewEmail": "Bitte anmelden oder auf „Neue E-Mail erstellen“ klicken, um eine neue Adresse zu erhalten.",
|
||||||
|
"components.WebhookComponent.fillInDemoTip": "Bitte URL und weitere Einstellungen an deine Umgebung anpassen",
|
||||||
|
"components.SendBox.pleaseSelectMail": "Bitte eine E-Mail zum Anzeigen auswählen.",
|
||||||
|
"views.admin.Account.pleaseSelectAddress": "Bitte eine Adresse auswählen",
|
||||||
|
"components.MailBox.pleaseSelectMail": "Bitte eine E-Mail auswählen",
|
||||||
|
"views.admin.UserManagement.prefix": "Präfix",
|
||||||
|
"components.WebhookComponent.presets": "Voreinstellungen",
|
||||||
|
"views.index.SendMail.preview": "Vorschau",
|
||||||
|
"views.admin.SendMail.preview": "Vorschau",
|
||||||
|
"views.admin.UserOauth2Settings.iconPreview": "Vorschau",
|
||||||
|
"components.MailBox.prevMail": "Zurück",
|
||||||
|
"views.index.SimpleIndex.prevPage": "Zurück",
|
||||||
|
"components.MailBox.query": "Abfragen",
|
||||||
|
"views.user.UserMailBox.query": "Abfragen",
|
||||||
|
"views.Index.query": "Abfragen",
|
||||||
|
"views.admin.SendBox.query": "Abfragen",
|
||||||
|
"views.admin.Account.query": "Abfragen",
|
||||||
|
"views.admin.SenderAccess.query": "Abfragen",
|
||||||
|
"views.admin.UserManagement.query": "Abfragen",
|
||||||
|
"views.admin.Mails.query": "Abfragen",
|
||||||
|
"views.Admin.qucickSetup": "Schnelleinrichtung",
|
||||||
|
"views.index.SendMail.toMailEmpty": "Die Empfängeradresse ist leer",
|
||||||
|
"views.admin.SendMail.toMailEmpty": "Die Empfängeradresse ist leer",
|
||||||
|
"views.index.SendMail.toName": "Name und Adresse des Empfängers; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
|
||||||
|
"views.admin.SendMail.toName": "Name und Adresse des Empfängers; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
|
||||||
|
"components.MailBox.refresh": "Aktualisieren",
|
||||||
|
"components.Turnstile.refresh": "Aktualisieren",
|
||||||
|
"components.SendBox.refresh": "Aktualisieren",
|
||||||
|
"views.index.SimpleIndex.refreshMails": "Aktualisieren",
|
||||||
|
"components.MailBox.refreshAfter": "Aktualisieren in {msg} Sekunden",
|
||||||
|
"views.index.SimpleIndex.refreshAfter": "Aktualisieren in {msg} Sekunden",
|
||||||
|
"views.admin.AccountSettings.regex_too_long": "Regex-Muster zu lang (max. 200 Zeichen)",
|
||||||
|
"views.user.UserLogin.register": "Registrieren",
|
||||||
|
"views.user.UserSettings.renamePasskey": "Passkey umbenennen",
|
||||||
|
"views.admin.UserOauth2Settings.userEmailReplace": "Ersetzungsvorlage",
|
||||||
|
"components.MailBox.reply": "Antworten",
|
||||||
|
"components.MailContentRenderer.reply": "Antworten",
|
||||||
|
"views.index.SendMail.requestAccess": "Zugriff anfordern",
|
||||||
|
"views.user.UserLogin.resetPassword": "Zurücksetzen Passwort",
|
||||||
|
"views.admin.Account.resetPassword": "Zurücksetzen Passwort",
|
||||||
|
"views.admin.UserManagement.resetPassword": "Zurücksetzen Passwort",
|
||||||
|
"views.index.SendMail.rich text": "Rich Text",
|
||||||
|
"views.admin.SendMail.rich text": "Rich Text",
|
||||||
|
"views.common.Appearance.right": "rechts",
|
||||||
|
"views.admin.UserManagement.role": "Rolle",
|
||||||
|
"views.admin.RoleAddressConfig.role": "Rolle",
|
||||||
|
"views.Admin.roleAddressConfig": "Rollenbasierte Adresskonfiguration",
|
||||||
|
"views.admin.AccountSettings.rule_index": "Regel",
|
||||||
|
"views.Index.s3Attachment": "S3-Anhang",
|
||||||
|
"components.WebhookComponent.save": "Speichern",
|
||||||
|
"views.index.AutoReply.save": "Speichern",
|
||||||
|
"views.admin.AiExtractSettings.save": "Speichern",
|
||||||
|
"views.admin.UserOauth2Settings.save": "Speichern",
|
||||||
|
"views.admin.IpBlacklistSettings.save": "Speichern",
|
||||||
|
"views.admin.Telegram.save": "Speichern",
|
||||||
|
"views.admin.UserSettings.save": "Speichern",
|
||||||
|
"views.admin.RoleAddressConfig.save": "Speichern",
|
||||||
|
"views.admin.AccountSettings.save": "Speichern",
|
||||||
|
"views.admin.Maintenance.save": "Speichern",
|
||||||
|
"views.admin.Webhook.save": "Speichern",
|
||||||
|
"views.admin.Maintenance.saveSuccess": "Erfolgreich gespeichert",
|
||||||
|
"views.admin.UserOauth2Settings.successTip": "Erfolgreich gespeichert",
|
||||||
|
"views.admin.IpBlacklistSettings.successTip": "Erfolgreich gespeichert",
|
||||||
|
"views.admin.UserSettings.successTip": "Erfolgreich gespeichert",
|
||||||
|
"views.admin.AccountSettings.successTip": "Erfolgreich gespeichert",
|
||||||
|
"components.MailBox.saveToS3": "In S3 speichern",
|
||||||
|
"components.MailContentRenderer.saveToS3": "In S3 speichern",
|
||||||
|
"views.Index.saveToS3Success": "In S3 gespeichert",
|
||||||
|
"components.MailBox.selectAll": "Alles auf dieser Seite auswählen",
|
||||||
|
"components.SendBox.selectAll": "Alles auf dieser Seite auswählen",
|
||||||
|
"views.admin.Account.selectAll": "Alles auf dieser Seite auswählen",
|
||||||
|
"views.admin.AccountSettings.select_domain": "Domain auswählen",
|
||||||
|
"views.admin.Account.selectedItems": "Ausgewählt",
|
||||||
|
"views.index.SendMail.send": "Senden",
|
||||||
|
"views.admin.SendMail.send": "Senden",
|
||||||
|
"views.Index.sendbox": "Postausgang",
|
||||||
|
"views.Admin.sendBox": "Postausgang",
|
||||||
|
"views.user.AddressManagement.send_count": "Sendeanzahl",
|
||||||
|
"views.admin.Account.send_count": "Sendeanzahl",
|
||||||
|
"views.admin.UserAddressManagement.send_count": "Sendeanzahl",
|
||||||
|
"views.Index.sendmail": "E-Mail senden",
|
||||||
|
"views.Admin.sendMail": "E-Mail senden",
|
||||||
|
"views.index.SendMail.send_balance": "Sendeguthaben der aktuellen Adresse",
|
||||||
|
"views.admin.Statistics.sendMailCount": "Anzahl gesendeter E-Mails",
|
||||||
|
"views.admin.AccountSettings.send_mail_limit": "Sende-Limit",
|
||||||
|
"views.user.UserLogin.sendVerificationCode": "Bestätigungscode senden",
|
||||||
|
"views.Admin.senderAccess": "Sender-Zugriffssteuerung",
|
||||||
|
"views.admin.SendMail.fromMailEmpty": "Die Absenderadresse ist leer",
|
||||||
|
"views.index.AutoReply.sourcePrefix": "Absenderfilter",
|
||||||
|
"components.AiExtractInfo.serviceLink": "Servicelink",
|
||||||
|
"views.index.AutoReply.settings": "Einstellungen",
|
||||||
|
"views.index.AccountSettings.showAddressCredential": "Adresszugangsdaten anzeigen",
|
||||||
|
"components.MailBox.attachments": "Anhänge anzeigen",
|
||||||
|
"components.MailBox.showHtmlMail": "HTML-Mail anzeigen",
|
||||||
|
"components.MailContentRenderer.showHtmlMail": "HTML-Mail anzeigen",
|
||||||
|
"views.admin.Account.showCredential": "E-Mail-Adresszugangsdaten anzeigen",
|
||||||
|
"views.user.UserSettings.showPasskeyList": "Passkey-Liste anzeigen",
|
||||||
|
"components.MailBox.showTextMail": "Textmail anzeigen",
|
||||||
|
"components.MailContentRenderer.showTextMail": "Textmail anzeigen",
|
||||||
|
"views.Index.enterSimpleMode": "Einfacher Modus",
|
||||||
|
"components.MailContentRenderer.size": "Größe",
|
||||||
|
"views.admin.Account.source_meta": "Quelle",
|
||||||
|
"views.admin.AccountSettings.source_patterns": "Regex für Quelladresse (optional)",
|
||||||
|
"views.admin.Maintenance.sqlStatement": "SQL-Anweisung (nur DELETE)",
|
||||||
|
"views.user.UserOauth2Callback.stateNotMatch": "State stimmt nicht überein",
|
||||||
|
"views.Admin.statistics": "Statistiken",
|
||||||
|
"views.Header.status": "Status",
|
||||||
|
"views.admin.IpBlacklistSettings.enable_whitelist_tip": "Strenger Modus: Nur IPs aus der Freigabeliste dürfen auf limitierte APIs zugreifen. Alle anderen IPs werden abgewiesen.",
|
||||||
|
"views.index.AutoReply.subject": "Betreff",
|
||||||
|
"views.index.SendMail.subject": "Betreff",
|
||||||
|
"views.admin.SendMail.subject": "Betreff",
|
||||||
|
"views.index.SendMail.subjectEmpty": "Der Betreff ist leer",
|
||||||
|
"views.admin.SendMail.subjectEmpty": "Der Betreff ist leer",
|
||||||
|
"components.AiExtractInfo.subscriptionLink": "Abonnement-Link",
|
||||||
|
"views.user.AddressManagement.success": "Erfolg",
|
||||||
|
"views.index.TelegramAddress.success": "Erfolg",
|
||||||
|
"views.index.LocalAddress.success": "Erfolg",
|
||||||
|
"views.admin.UserAddressManagement.success": "Erfolg",
|
||||||
|
"components.WebhookComponent.successTip": "Erfolg",
|
||||||
|
"components.MailBox.success": "Erfolg",
|
||||||
|
"components.SendBox.success": "Erfolg",
|
||||||
|
"views.index.AccountSettings.success": "Erfolg",
|
||||||
|
"views.index.AutoReply.success": "Erfolg",
|
||||||
|
"views.admin.AiExtractSettings.successTip": "Erfolg",
|
||||||
|
"views.admin.Account.success": "Erfolg",
|
||||||
|
"views.admin.SenderAccess.success": "Erfolg",
|
||||||
|
"views.admin.UserManagement.success": "Erfolg",
|
||||||
|
"views.admin.Telegram.successTip": "Erfolg",
|
||||||
|
"views.admin.RoleAddressConfig.successTip": "Erfolg",
|
||||||
|
"views.admin.Webhook.successTip": "Erfolg",
|
||||||
|
"views.admin.CreateAccount.successTip": "Erfolgreich erstellt",
|
||||||
|
"views.admin.Telegram.globalMailPushListTip": "Unterstützt chat_id von Privat-/Gruppen-/Kanal-Chats. Sende deinem Bot eine Nachricht und rufe dann diesen Link auf, um die chat_id zu sehen: https://api.telegram.org/bot<Ersetze durch dein BOT TOKEN>/getUpdates",
|
||||||
|
"views.user.AddressManagement.targetUserEmail": "Zielbenutzer-E-Mail",
|
||||||
|
"views.admin.Telegram.telegramAllowList": "Telegram-Freigabeliste (Chat-ID manuell eingeben)",
|
||||||
|
"views.Admin.telegram": "Telegram-Bot",
|
||||||
|
"views.admin.Telegram.miniAppUrl": "Telegram Mini App URL",
|
||||||
|
"components.WebhookComponent.test": "Test",
|
||||||
|
"views.index.SendMail.text": "Text",
|
||||||
|
"views.admin.SendMail.text": "Text",
|
||||||
|
"views.user.UserSettings.passordTip": "Der Server erhält nur den Hash des Passworts und niemals das Klartext-Passwort. Daher kann er dein Passwort weder sehen noch wiederherstellen. Wenn der Administrator die E-Mail-Verifizierung aktiviert, kannst du es im Inkognito-Modus zurücksetzen.",
|
||||||
|
"views.index.LocalAddress.tip": "Diese Adressen werden in deinem Browser gespeichert und können verloren gehen, wenn du den Browser-Cache leerst.",
|
||||||
|
"views.admin.UserOauth2Settings.tip": "Drittanbieter-Login verwendet automatisch die E-Mail-Adresse des Benutzers zur Registrierung eines Kontos (dieselbe E-Mail gilt als dasselbe Konto). Das Konto entspricht dem regulär registrierten Konto, und das Passwort kann auch über „Passwort vergessen“ gesetzt werden.",
|
||||||
|
"views.admin.AccountSettings.send_mail_limit_tip": "Dies gilt für alle Sendekanäle. Verwende -1 für unbegrenzt und 0, um das Senden zu blockieren.",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS erlaubt bereits zufällige oder manuelle Subdomains für gelistete Basisdomains. Dieser Schalter erlaubt API-Aufrufern zusätzlich Subdomains unter anderen erlaubten Basisdomains.",
|
||||||
|
"views.index.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
|
||||||
|
"views.admin.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
|
||||||
|
"views.common.Appearance.top": "oben",
|
||||||
|
"views.user.AddressManagement.transferAddress": "Adresse übertragen",
|
||||||
|
"views.user.AddressManagement.transferAddressTip": "Wenn du diese Adresse an einen anderen Benutzer überträgst, wird sie aus deinem Konto entfernt und in dessen Konto verschoben. Möchtest du die Adresse wirklich übertragen?",
|
||||||
|
"views.common.Appearance.useSideMargin": "Seitliche Abstände links und rechts auf der Seite aktivieren",
|
||||||
|
"views.admin.AiExtractSettings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
|
||||||
|
"views.admin.UserOauth2Settings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
|
||||||
|
"views.admin.Telegram.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
|
||||||
|
"views.admin.UserSettings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
|
||||||
|
"views.admin.AccountSettings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
|
||||||
|
"views.admin.Webhook.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
|
||||||
|
"views.admin.IpBlacklistSettings.manualInputPrompt": "Muster eingeben und Enter drücken, um es hinzuzufügen",
|
||||||
|
"views.user.AddressManagement.unbindAddress": "Adressverknüpfung lösen",
|
||||||
|
"views.index.TelegramAddress.unbindMailAddress": "Mailadressverknüpfung lösen",
|
||||||
|
"views.index.LocalAddress.unbindMailAddress": "Mailadresszugangsdaten lösen",
|
||||||
|
"components.MailBox.unselectAll": "Auswahl aufheben",
|
||||||
|
"components.SendBox.unselectAll": "Auswahl aufheben",
|
||||||
|
"views.admin.Account.unselectAll": "Auswahl aufheben",
|
||||||
|
"views.admin.Account.updated_at": "Aktualisiert am",
|
||||||
|
"views.user.UserSettings.updated_at": "Aktualisiert am",
|
||||||
|
"components.WebhookComponent.urlMissing": "URL ist erforderlich",
|
||||||
|
"views.common.Appearance.useIframeShowMail": "HTML-Mail per iframe anzeigen",
|
||||||
|
"views.admin.CreateAccount.enableRandomSubdomain": "Zufällige Subdomain verwenden",
|
||||||
|
"views.common.Login.enableRandomSubdomain": "Zufällige Subdomain verwenden",
|
||||||
|
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com",
|
||||||
|
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
|
||||||
|
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
|
||||||
|
"views.Header.userCenter": "Benutzerbereich",
|
||||||
|
"views.Admin.user": "Benutzer",
|
||||||
|
"components.AddressSelect.linkedMailboxes": "Verknüpfte Postfächer",
|
||||||
|
"views.Admin.loginViaUserAdmin": "Benutzerkonto (Administratorrolle)",
|
||||||
|
"views.admin.Statistics.userCount": "Benutzeranzahl",
|
||||||
|
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
|
||||||
|
"views.index.AddressBar.userCenter": "Benutzerbereich",
|
||||||
|
"views.Admin.user_management": "Benutzerverwaltung",
|
||||||
|
"views.Admin.user_settings": "Benutzereinstellungen",
|
||||||
|
"views.User.user_settings": "Benutzereinstellungen",
|
||||||
|
"components.AiExtractInfo.authCode": "Bestätigungscode",
|
||||||
|
"views.user.UserLogin.verifyCode": "Bestätigungscode",
|
||||||
|
"views.user.UserLogin.verifyCodeSent": "Bestätigungscode gesendet, läuft ab in {timeout} Sekunden",
|
||||||
|
"views.admin.AccountSettings.verified_address_list": "Liste verifizierter Adressen (kann E-Mails über die interne cf-API senden)",
|
||||||
|
"views.admin.UserSettings.verifyMailSender": "Mail-Absender verifizieren",
|
||||||
|
"components.MailContentRenderer.attachments": "Anhänge anzeigen",
|
||||||
|
"views.admin.Account.viewMails": "E-Mails anzeigen",
|
||||||
|
"views.admin.Account.viewSendBox": "Postausgang anzeigen",
|
||||||
|
"views.user.UserLogin.waitforVerifyCode": "Warten {timeout} Sekunden",
|
||||||
|
"views.admin.Webhook.webhookAllowList": "Webhook-Freigabeliste (erlaubte Mailadresse eingeben und Enter drücken)",
|
||||||
|
"views.admin.Webhook.notEnabled": "Webhook ist nicht aktiviert",
|
||||||
|
"components.WebhookComponent.notEnabled": "Webhook ist für dich nicht aktiviert",
|
||||||
|
"views.Index.webhookSettings": "Webhook-Einstellungen",
|
||||||
|
"views.Admin.webhookSettings": "Webhook-Einstellungen",
|
||||||
|
"views.admin.AiExtractSettings.disabledTip": "Wenn deaktiviert, verarbeitet die KI-Extraktion alle E-Mail-Adressen",
|
||||||
|
"views.admin.AiExtractSettings.enableAllowListTip": "Wenn aktiviert, verarbeitet die KI-Extraktion nur E-Mails an Adressen auf der Freigabeliste",
|
||||||
|
"views.admin.CreateAccount.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
|
||||||
|
"views.admin.CreateAccount.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
|
||||||
|
"views.admin.CreateAccount.normalSubdomain": "Normale Domain",
|
||||||
|
"views.common.Login.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
|
||||||
|
"views.common.Login.normalSubdomain": "Normale Domain",
|
||||||
|
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
|
||||||
|
"views.admin.AiExtractSettings.allowListTip": "Der Platzhalter * passt auf beliebige Zeichen; z. B. passt *{'@'}example.com auf alle Adressen der Domain example.com",
|
||||||
|
"views.Admin.workerconfig": "Worker-Konfiguration",
|
||||||
|
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "Die Worker-Umgebungsvariable ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH ist derzeit false. Der gespeicherte Admin-Schalter kann geändert werden, wird aber erst wirksam, wenn die Umgebungsvariable aktiviert oder entfernt wird.",
|
||||||
|
"views.common.Login.getNewEmailTip3": "Du kannst eine Domain aus der Dropdown-Liste auswählen.",
|
||||||
|
"views.admin.AccountSettings.tip": "Die folgenden Mehrfachauswahlwerte können manuell eingegeben und mit Enter hinzugefügt werden",
|
||||||
|
"components.MailBox.emptyInbox": "Dein Posteingang ist leer",
|
||||||
|
"views.index.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
|
||||||
|
"views.admin.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
|
||||||
|
"components.AddressCredentialModal.addressCredential": "Adresszugangsdaten",
|
||||||
|
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
|
||||||
|
"components.AddressCredentialModal.addressPassword": "Adresspasswort",
|
||||||
|
"components.AddressCredentialModal.agentAccess": "AI Agent",
|
||||||
|
"components.AddressCredentialModal.agentAccessTip": "Verwende dieses Postfach in einem AI Agent mit dem Address JWT und den parsed-mail APIs.",
|
||||||
|
"components.AddressCredentialModal.agentConfig": "Agent-Konfiguration",
|
||||||
|
"components.AddressCredentialModal.agentSkill": "Agent skill",
|
||||||
|
"components.AddressCredentialModal.apiBase": "API-Basisadresse",
|
||||||
|
"components.AddressCredentialModal.autoLoginLink": "Auto-Login-Link",
|
||||||
|
"components.AddressCredentialModal.copyFailed": "Kopieren fehlgeschlagen",
|
||||||
|
"components.AddressCredentialModal.copySection": "Kopieren",
|
||||||
|
"components.AddressCredentialModal.copySuccess": "Kopiert",
|
||||||
|
"components.AddressCredentialModal.currentAddress": "Aktuelle Adresse",
|
||||||
|
"components.AddressCredentialModal.docs": "Dokumentation",
|
||||||
|
"components.AddressCredentialModal.imapHost": "IMAP-Host",
|
||||||
|
"components.AddressCredentialModal.imapPort": "IMAP-Port",
|
||||||
|
"components.AddressCredentialModal.password": "Passwort",
|
||||||
|
"components.AddressCredentialModal.plainOrProxyTls": "Klartext oder Proxy-TLS",
|
||||||
|
"components.AddressCredentialModal.security": "Sicherheit",
|
||||||
|
"components.AddressCredentialModal.smtpHost": "SMTP-Host",
|
||||||
|
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
|
||||||
|
"components.AddressCredentialModal.smtpImapTip": "Verwende diese Werte in Mail-Clients, nachdem der Administrator den SMTP/IMAP-Proxy konfiguriert hat. Als Passwort kannst du den hier angezeigten Address JWT oder ein vorhandenes Adresspasswort verwenden.",
|
||||||
|
"components.AddressCredentialModal.smtpPort": "SMTP-Port",
|
||||||
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
|
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||||
|
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||||
|
"components.AddressCredentialModal.username": "Benutzername",
|
||||||
|
"views.User.send_mail": "E-Mail senden",
|
||||||
|
"views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||||
|
"views.user.UserSendBox.sendbox": "Gesendet",
|
||||||
|
"components.MailContentRenderer.markAsRead": "Als gelesen markieren",
|
||||||
|
"components.MailContentRenderer.markAsUnread": "Als ungelesen markieren",
|
||||||
|
"components.MailContentRenderer.loadRemoteImages": "Bilder laden",
|
||||||
|
"components.MailContentRenderer.remoteImagesBlocked": "{count} externe Ressourcen wurden zum Schutz deiner Privatsphäre blockiert",
|
||||||
|
"views.user.AddressManagement.itemCount": "Gesamt",
|
||||||
|
"views.index.SimpleIndex.readStatusUpdated": "Lesestatus aktualisiert",
|
||||||
|
"views.common.Appearance.autoLoadRemoteImages": "Externe Bilder im E-Mail-Inhalt automatisch laden"
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user