Compare commits

...
Author SHA1 Message Date
dreamhunter2333 288634c774 feat: add password-only mailbox login and bound password reset 2026-09-10 03:02:39 +08:00
Dream Hunter 066fcfc8c2 fix: validate mailbox credentials and isolate E2E APIs (#1141) 2026-09-09 18:11:53 +08:00
Dream Hunter f88852a353 fix: refresh access tokens from structured API errors (#1140)
fix: refresh user access tokens from API errors without blocking mailbox access
2026-09-09 13:39:49 +08:00
Dream Hunter 0a0a56e653 fix: clarify cleanup failures and test address activity controls (#1139)
* fix: use general cleanup failure messages

* fix: limit cleanup message change to false results

* test: cover address activity controls in existing e2e workers

* test: keep the default e2e worker entry unchanged

* test: extend existing address activity e2e coverage

* test: prepare historical timestamps through shared D1 files

* test: assert exact cleanup validation errors

* test: exclude D1 metadata when locating the test database

* test: exercise address activity through real API flows

* test: prepare activity fixtures with admin APIs
2026-09-07 00:32:52 +08:00
Dream Hunter 4ddd502a60 chore: upgrade dependencies (#1137) 2026-09-06 23:24:26 +08:00
Dream Hunter 406d1956b8 feat: add address activity disable switch (#1138)
* feat: add address activity disable switch

* style: separate address activity disable guards

* docs: clarify address activity switch behavior
2026-09-06 23:08:41 +08:00
Dream Hunter 66ab5a05df feat: add redemption code system (#1133)
feat: add redemption code support
2026-09-05 22:19:17 +08:00
Dream Hunter 8e1bfb8af2 fix: load AdSense script without event attributes (#1136) 2026-09-05 00:32:34 +08:00
Dream Hunter fc363cc9c5 feat: support runtime frontend configuration (#1135) 2026-09-05 00:15:38 +08:00
Dream Hunter 806ec1aeab fix: prevent Admin password dialog flash (#1134)
* fix: wait for admin access settings before rendering

* test: use frontend route for admin loading check

* test: always release delayed admin settings request

* test: track Admin password dialog during loading
2026-09-04 12:10:27 +08:00
Dream Hunter 3c505db9d0 feat: add admin API IP whitelist (#1131)
* feat: add admin API IP whitelist

* fix: harden admin IP whitelist checks
2026-09-02 01:21:24 +08:00
Dream Hunter 5fd181d9c0 refactor(frontend): unify mailbox terminology
Closes #1129
2026-08-31 22:36:08 +08:00
Dream Hunter 70206c61ef feat: add single-mail read status (#1125) 2026-08-31 13:11:07 +08:00
Dream Hunter f92b059aac feat: add Admin random address name generation (#1127)
feat: add admin random address name
2026-08-27 23:06:54 +08:00
Dream Hunter 5dbb6107dd feat: add user send mail and sent box (#1122)
* feat: add user send mail client

* fix: align user mail navigation

* fix: shorten address credential action

* test: cover user mail ownership boundaries

* fix: address user mail review feedback

* fix: disambiguate user mail e2e heading

* fix: minimize shared sent box changes

* refactor: isolate user send mail page

* refactor: reuse bound address lookup

* fix: clarify user sent box naming

* refactor: decouple user send API from roles

* fix: align user send mail behavior

* fix: align user send role and rate limits

* test: isolate user send rate limits

* test: initialize rate limit worker database

* refactor: simplify user send rate limit

* refactor: inline user send rate limit path

* refactor: simplify user send limiter key

* refactor: keep existing rate limit behavior

* style: simplify user send rate limit condition

* style: group user send rate limit condition

* fix: bind user role token to account

* fix: keep user sender selection available
2026-08-25 14:14:42 +08:00
Dream Hunter dccca92928 fix: align send mail fields and editor caret (#1121)
fix(frontend): align send mail fields and editor caret
2026-08-22 19:31:54 +08:00
Dream Hunter 005d74bfde feat: improve send mail composer (#1120)
feat(frontend): improve send mail composer
2026-08-22 19:08:09 +08:00
Dream Hunter aeb2ac687d fix: 修复 Admin 二级标签页状态丢失 (#1118)
fix: reset admin nested tabs on section switch
2026-08-20 02:20:40 +08:00
Dream Hunter 108b8ef4ac feat: show D1 storage capacity in admin (#1117)
* feat: show D1 storage usage in admin

* test: add D1 storage E2E coverage

* test: use exact D1 storage labels
2026-08-19 16:16:54 +08:00
Dream Hunter 2762741226 feat: upgrade version to v1.12.0 (#1116)
- Update version number to 1.12.0 in all package.json files
- Add v1.12.0 placeholder in CHANGELOG.md
- Move (main) marker from v1.11.0 to v1.12.0
2026-08-19 13:56:17 +08:00
Dream Hunter 3ffa16234f chore: upgrade dependencies (#1115) 2026-08-19 13:45:25 +08:00
Dream Hunter 3bcc0c19ce fix: clarify account and address terminology (#1113)
* fix: clarify account and address terminology

* fix: narrow account and address terminology changes

* fix: simplify mailbox settings label

* fix: align mailbox translations and send docs
2026-08-19 13:31:55 +08:00
Dream Hunter a3c62de42f feat: allow custom subdomains in create UI (#1109)
* feat: allow custom subdomains in create UI

* test: stabilize custom subdomain browser flow

* test: fix normalized address expectation

* fix: gate custom subdomain input by setting

* fix: scope custom subdomains to random domains

* test: recreate random subdomain manually

* refactor: minimize custom subdomain changes

* refactor: reduce custom subdomain changes

* test: fix custom subdomain locator

* refactor: clarify manual subdomain validation

* test: target visible custom subdomain input

* refactor: simplify manual subdomain validation

* refactor: use subdomain mode selector

* style: stack subdomain modes vertically

* test: click visible subdomain mode labels
2026-08-19 12:10:09 +08:00
SimonFoobar648andSimonFoobar648 624fc9bb96 docs: fix broken star history chart (#1111)
docs: fix broken star history chart in READMEs and docs

The star history chart in the READMEs and vitepress docs is currently broken because the upstream service no longer works due to GitHub stargazer API restrictions. Switch the chart images to the star-history.dera.page mirror, which uses a different data source that requires no API token, so the chart renders correctly again.

Co-authored-by: SimonFoobar648 <245426116+SimonFoobar648@users.noreply.github.com>
2026-08-14 23:37:06 +08:00
Dream Hunter 12152fc893 perf: limit indexed cleanup task batches (#1107)
Limit mail, sent-mail, and indexed address cleanup to configurable batches. Includes E2E coverage and documentation.
2026-08-09 23:32:05 +08:00
Dream Hunter a09ede8944 perf: paginate user addresses and optimize ownership queries (#1105)
* perf: paginate user addresses and optimize mail ownership queries

* docs: document user address pagination

* fix: address pagination review feedback

* fix: cover user address pagination flows

* test: fix user mailbox tab selector

* test: stabilize remote address search flow

* test: stabilize user address browser flow

* fix: preserve address pagination compatibility

* refactor: simplify bound address query types

* refactor: reuse list query for bound addresses

* fix: preserve paginated address totals

* fix: preserve bound address helper contracts

* fix: require pagination for user addresses

* refactor: keep shared pagination behavior unchanged

* fix: align pagination docs and tests

* fix: clear stale address selections

* refactor: simplify user address pagination

* refactor: limit user address changes to pagination

* test: select a visible mailbox address

* fix: preserve bound address response fields
2026-08-09 19:23:41 +08:00
Dream Hunter f9281818e9 chore: upgrade dependencies (#1106) 2026-08-07 11:06:54 +08:00
Dream Hunter 5553c6484a perf: throttle address activity updates (#1104)
* perf: throttle address activity updates

* docs: record address activity write throttling

* refactor: inline address activity interval

* test: cover address activity throttling
2026-08-07 10:50:38 +08:00
Dream Hunter d04c1a865d feat: upgrade version to v1.11.0 (#1100)
- Update version number to 1.11.0 in all package.json files
- Add v1.11.0 placeholder in CHANGELOG.md
- Move (main) marker from v1.10.0 to v1.11.0
2026-07-31 20:52:09 +08:00
Dream Hunter 116ddc7324 feat: add admin mail detail API (#1099)
* feat: add admin mail detail API

* docs: clarify admin mail detail response
2026-07-31 15:36:00 +08:00
Dream Hunter 2dcbad40ad chore: upgrade e2e dependencies (#1098) 2026-07-31 12:43:29 +08:00
Dream Hunter 95badf5aed chore: upgrade dependencies (#1097) 2026-07-31 11:36:37 +08:00
Dream Hunter b3666c0900 fix: harden remote content policy edge cases (#1095)
* fix: harden remote content filtering edge cases

* fix: preserve safe escaped CSS

* test: cover unsafe navigation protocols
2026-07-29 15:36:11 +08:00
Josh TsaiandClaude Opus 5 e499211197 feat: add setting to disable auto-loading external images in emails (#1092)
* feat: add setting to disable auto-loading external images in emails

Adds a privacy setting (default off) that blocks remote images in email
content until the user explicitly loads them per message. Blocked images
are replaced with a placeholder; a banner allows one-click loading.

Closes #1073

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(frontend): block remote content with DOMPurify and an allowlist policy

Address review on the blocking logic. The first pass matched quoted
`<img src="http...">` with a regex, which left unquoted src, srcset,
`<source>`, CSS background-image, SVG `<image href>` and entity-encoded
schemes fetching as usual, and replaced only `src` on an element that also
carried `srcset` -- so the browser still had a remote candidate to prefer
while the UI claimed the image was blocked.

Two changes rather than a wider regex:

Sanitising is delegated to DOMPurify, which is already a dependency. The
hard part here is not enumerating attributes but surviving the parser: a
hand-written pass over a DOMParser tree still missed that `<noscript>` is
parsed as markup where scripting is off and as raw text where it is on, so a
`</noscript>` smuggled into an attribute value reopens the document at
render time and revives an `<img>` the cleaner never saw. Elements that
fetch by themselves or change how relative URLs resolve -- base, meta,
script, link, iframe, object, embed, noscript -- are dropped in this mode.
`<style>` is kept so layout survives, with its url(), image-set() and
@import references filtered.

URL classification is an allowlist. Asking "does this look remote?" means
enumerating every disguise -- backslash authorities, tab/newline/control
characters the URL parser strips, CSS escapes, schemes with no slashes --
and losing to the first one not thought of. Asking "can I prove this is
local?" fails closed instead: cid:, data:image/, blob: and relative paths
are kept, everything else is blocked. Relative paths are only safe because
`<base>` is removed, which is what stopped it re-pointing them at a tracker.

The blocked URL is discarded rather than parked in a data-* attribute, so
"the cleaned body contains no remote URL at all" is directly assertable;
restoring images re-renders from the untouched source.

Also: blob: is added to the allowed schemes -- DOMPurify's default list
omits it, and email-parser rewrites cid: attachments into blob: URLs, so
without it every inline image would be stripped along with the trackers.

The policy lives in its own module with its own tests (30 attack vectors,
7 preservation cases); email-parser.js goes back to MIME parsing only. The
per-mail override no longer initialises from the global setting, and the
banner reports the blocked count as the PR description promised.

Refs #1073

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:28:15 +08:00
Dream Hunter 342fe22e4f chore: upgrade dependencies (#1093)
Refresh dependencies and lockfiles across frontend, Worker, Pages, and VitePress documentation packages. Update Wrangler to 4.114.0 and align Cloudflare Workers types with its peer requirements.

Validated with frontend tests/build, Worker build/lint, docs build, and repository E2E.
2026-07-28 18:05:53 +08:00
tuanaiseo 8c883b269f fix(frontend): sanitize announcement HTML (#1039)
Sanitize HTML announcements in both the About page and startup notification through a shared DOMPurify helper. Add regression tests and bilingual changelog entries.

Co-authored-by: tuanaiseo <tuanaiseo@gmail.com>
2026-07-28 17:49:32 +08:00
Josh Tsaiandbounce12340 4c1e593d07 fix(imap-proxy): persist IMAP flags and mark mail as read (#1090)
Fix IMAP flag persistence so read/unread state survives reconnects, and align SEARCH/FETCH behavior with persisted flags.

Co-authored-by: bounce12340 <bounce12340@users.noreply.github.com>
2026-07-27 20:15:25 +08:00
Josh TsaiandClaude Fable 5 7eaa3b3b8e test: |Worker| add junk_mail_policy regression tests for issue #1084 (#1089)
Cover the junk-mail policy behavior fixed in #1085:
- none/neutral results for SPF/DKIM/DMARC are treated as the method
  being absent and do not trigger JUNK_MAIL_CHECK_LIST rejection
- explicit fail results are still rejected
- JUNK_MAIL_FORCE_PASS_LIST only accepts an explicit pass

Run with: node --test-isolation=none --test worker/src/email/junk_mail_policy.test.mjs

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 20:21:46 +08:00
tura-ai-agent 0581632b59 docs: add Japanese README (#1088)
Add a complete Japanese README and link it from the Chinese and English READMEs.
2026-07-23 15:09:49 +08:00
wlnxing 4ce22ef249 fix: 按规范处理 SPF、DKIM 和 DMARC 认证结果 (#1085)
fix: align SPF, DKIM, and DMARC junk mail checks with RFC standards
2026-07-19 16:13:47 +08:00
Maxim Lapan 99b332345b fix: clean up related mails before deleting address in admin API (#1081)
* fix: clean up related mails before deleting address in admin API

* fix: run admin address deletion in a single D1 batch
2026-07-11 14:06:14 +08:00
Dream Hunter 565bb839db fix: hide mobile preview line setting (#1080) 2026-07-08 14:41:49 +08:00
YewFence dbd1f8706d feat: 增加全宽列表视图功能 (#1079)
* feat(mailbox): add list view mode

- Add a toggleable list view for the mailbox, with a settings option and back button.
- deselect mail on second click in list view
- set current mail on row click in multi-action mode

* feat(mailbox): add configurable body preview line clamp

Allow users to set the number of preview lines (0–5) for mail body in the list view via a slider in Appearance settings. Includes i18n support for the new option and its "Off" state.

* chore: clarify some i18n message in settings

include the following changes:
- The original "Mailbox Split Size" to "Left list width in two-column mailbox view"
- The description of new feature "Full-width mailbox list view"
sync all languages with the updated message

* docs: update changelog with recent UI improvements

- Added mailbox full-width list view and body preview lines settings
- Extended left panel width ratio range to 0
- Included English changelog translations

* docs: fix CHANGELOG improvements types

* fix: enable mail list preview line clamp settings on mobile
2026-07-08 14:21:10 +08:00
Dream Hunter 3f1d800e90 fix: validate AI extracted link domains (#1075)
* fix: validate AI extracted link domains

* fix: validate extracted links against full email content

* refactor: simplify AI link domain guard

* refactor: keep AI domain fix prompt-only
2026-07-04 16:56:12 +08:00
Dream Hunter 70b30c2494 chore: upgrade Twisted to stable 26.4.0 (#1071)
chore: upgrade twisted to stable 26.4.0
2026-06-25 00:23:17 +08:00
Dream Hunter 1a1dd720c8 chore: upgrade smtp proxy and e2e dependencies (#1069) 2026-06-24 23:59:16 +08:00
Dream Hunter 2d501d82cf chore: upgrade dependencies (#1068) 2026-06-24 23:39:10 +08:00
Chánh NiệmandCommandCodeBot 7c57592742 docs: add Resend DNS-only proxy warning to prevent #515-style verification failures (#1062)
docs: add Resend DNS-only proxy warning to send-mail config

Resend domain verification CNAME records must use DNS-only (gray
cloud) on Cloudflare. Proxied (orange cloud) records prevent
verification, and a single failed attempt can take hours before
retry. This is a recurring issue (#515) that the Resend setup
docs did not warn about.

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-06-19 15:49:03 +08:00
凉心anddreamhunter2333 41105ed803 fix: add page header padding for mobile layout (#1056)
* fix: add page header padding for mobile layout

* fix: limit page header padding to mobile layout

* docs: update changelog for mobile header fix

---------

Co-authored-by: dreamhunter2333 <dreamhunter2333@gmail.com>
2026-06-13 11:39:51 +08:00
Dream Hunter c924b71a5c fix: compact HTML before AI email extraction (#1057)
fix: compact html before ai extraction
2026-06-12 00:41:19 +08:00
Dream Hunter 4f0b44de2e chore: upgrade Vitest to v4 (#1052)
chore: upgrade vitest to v4
2026-06-03 23:29:56 +08:00
Dream Hunter f6fcbe793c feat: upgrade version to v1.10.0 (#1051)
- Update version number to 1.10.0 in all package.json files

- Add v1.10.0 placeholder in CHANGELOG.md
2026-06-03 22:14:44 +08:00
216 changed files with 16953 additions and 4620 deletions
@@ -1,6 +1,6 @@
---
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
@@ -23,18 +23,21 @@ The script runs the following in order:
| `worker/` | `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` |
| `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
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:
- `cd frontend && pnpm build`
- `cd worker && pnpm build && pnpm lint`
- `cd vitepress-docs && pnpm build`
3. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
4. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
- `cd e2e && npm test`
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
@@ -28,6 +28,7 @@ Upgrade the version number of the cloudflare_temp_email project.
4. Update the `VERSION` constant in `worker/src/constants.ts`.
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`.
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
@@ -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
@@ -53,4 +61,5 @@ feat: upgrade version to v{VERSION}
- Update version number to {VERSION} in all package.json files
- Add v{VERSION} placeholder in CHANGELOG.md
- Move (main) marker from v{OLD_VERSION} to v{VERSION}
```
+3
View File
@@ -142,3 +142,6 @@ pnpm-lock.yaml
e2e/test-results/
e2e/playwright-report/
e2e/.e2e-pids
# SMTP/IMAP proxy persisted IMAP flags (local dev / bare-metal runs)
smtp_proxy_server/data/
+94 -2
View File
@@ -6,7 +6,99 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.9.0(main)
## v1.12.0(main)
### Features
- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码
- 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
- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload``data-onerror` 属性
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
- fix: |用户发信| 用户地址发信接口支持角色无限额度
### Improvements
- fix: |Frontend| 统一邮箱、邮箱地址、用户账号与管理后台的核心界面术语,并补齐附加语言缺失的邮件状态与远程图片文案(issue #1129
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
### Testing
- test: |邮箱登录| 覆盖密码登录 JWT 有效期与续期、非法 token 类型,以及用户重置绑定邮箱密码的权限检查
- 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
@@ -154,7 +246,7 @@
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点
### Improvements
+94 -2
View File
@@ -6,7 +6,99 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.9.0(main)
## v1.12.0(main)
### Features
- feat: |Mailbox Login| Add a password-only switch that rejects legacy credentials, 30-day mailbox login JWTs renewed with less than 7 days remaining, and password reset for bound mailboxes
- 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
- fix: |Mailbox Login| Cache both login methods using backend settings without decoding JWTs in the frontend; use non-expiring Telegram binding tokens independently of web mailbox login JWTs
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish authentication errors to prompt for site and Admin login correctly; 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
- 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: |Mailbox Login| Cover password JWT lifetime and renewal, invalid token types, and authorization for bound mailbox password resets
- 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
@@ -154,7 +246,7 @@
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
- test: |Worker| Add `/admin/test/seed_mail` test endpoint
### Improvements
+5 -4
View File
@@ -30,7 +30,8 @@
<p align="center">
<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>
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
@@ -74,9 +75,9 @@
<summary>Star History(点击收缩/展开)</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<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>
+5 -4
View File
@@ -30,7 +30,8 @@
<p align="center">
<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>
> 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.
@@ -74,9 +75,9 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<summary>Star History (Click to expand/collapse)</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<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>
+202
View File
@@ -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="FeaturedHelloGitHub" 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/) | [![Deploy Backend Production](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | [![Deploy Frontend](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |
</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 WASMmail-parser-wasm
- **データベース**Cloudflare D1SQLite
- **ストレージ**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)
+1
View File
@@ -0,0 +1 @@
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
+15
View File
@@ -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);
+17
View File
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
is_unread INTEGER,
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 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
View File
@@ -1,5 +1,5 @@
# 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/*
+1
View File
@@ -33,6 +33,7 @@ config.server = {\
"/admin": { target: workerTarget, changeOrigin: true },\
"/user_api": { target: workerTarget, changeOrigin: true },\
"/open_api": { target: workerTarget, changeOrigin: true },\
"/redeem_api": { target: workerTarget, changeOrigin: true },\
"/external": { target: workerTarget, changeOrigin: true },\
"/health_check": { target: workerTarget, changeOrigin: true },\
},\
+3 -1
View File
@@ -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)
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 ./
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
COPY ${WRANGLER_TOML} wrangler.toml
EXPOSE 8787
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0", "--test-scheduled"]
+2 -1
View File
@@ -51,7 +51,8 @@ Test results and HTML reports are exported via volumes:
## Configuration
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
- `E2E_TEST_MODE = true` — enables test seed endpoint
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
- SMTP pointed at Mailpit container (`mailpit:1025`)
Test-only endpoints under `/__test/*` are registered in `e2e/fixtures/worker.ts` and are excluded from the production Worker.
+38 -1
View File
@@ -42,7 +42,7 @@ services:
dockerfile: e2e/Dockerfile.worker
ports:
- "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:
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
depends_on:
@@ -90,6 +90,24 @@ services:
start_period: 10s
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:
build:
context: ..
@@ -100,6 +118,19 @@ services:
worker:
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:
build:
context: ../smtp_proxy_server
@@ -147,7 +178,9 @@ services:
WORKER_URL_ENV_OFF: http://worker-env-off:8790
WORKER_GZIP_URL: http://worker-gzip:8788
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_ENV_OFF: https://frontend-env-off:5174
MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy
SMTP_PROXY_SMTP_PORT: "8025"
@@ -167,8 +200,12 @@ services:
condition: service_healthy
worker-send-mail-domain:
condition: service_healthy
worker-site-password:
condition: service_healthy
frontend:
condition: service_started
frontend-env-off:
condition: service_started
smtp-proxy:
condition: service_started
smtp-proxy-tls:
+3
View File
@@ -0,0 +1,3 @@
export class EmailMessage {
constructor(public from: string, public to: string, public raw: string) {}
}
@@ -1,37 +1,41 @@
import { Context } from 'hono'
import { getBooleanValue } from '../utils'
// Direct DB insert — bypasses the email() handler.
const seedMail = async (c: Context<HonoCustomType>) => {
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
return c.text("Not available", 404);
}
const { address, source, raw, message_id } = await c.req.json();
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 c.text("address and raw are required", 400);
return new Response("address and raw are required", { status: 400 });
}
if (raw.length > 1_000_000) {
return c.text("raw content too large", 400);
return new Response("raw content too large", { status: 400 });
}
if (message_id && message_id.length > 255) {
return c.text("message_id too long", 400);
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 c.env.DB.prepare(
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
+ ` VALUES (?, ?, ?, ?, datetime('now'))`
).bind(msgId, source || address, address, raw).run();
return c.json({ success });
+ ` 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 (c: Context<HonoCustomType>) => {
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
return c.text("Not available", 404);
}
const { from, to, raw, ai_extract_result } = await c.req.json();
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 c.text("from, to and raw are required", 400);
return new Response("from, to and raw are required", { status: 400 });
}
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
@@ -53,24 +57,19 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; },
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
};
const { email: emailHandler } = await import('../email');
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 env = ai_extract_result
? { ...c.env, ...aiExtractEnvOverrides }
: c.env;
const executionContext: ExecutionContext = {
waitUntil: () => {},
passThroughOnException: () => {},
props: {}
};
await emailHandler(mockMessage, env, executionContext);
const emailEnv = ai_extract_result
? { ...env, ...aiExtractEnvOverrides }
: env;
await emailHandler(mockMessage, emailEnv, ctx);
return c.json({
return Response.json({
success: !state.rejected,
replyCalled: state.replyCalled,
forwardedTo: state.forwardedTo,
+20 -11
View File
@@ -7,7 +7,9 @@ export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
export const 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_ENV_OFF = process.env.FRONTEND_URL_ENV_OFF || '';
export const MAILPIT_API = process.env.MAILPIT_API!;
export const TEST_DOMAIN = 'test.example.com';
@@ -27,10 +29,11 @@ export function hashPassword(password: string): string {
export async function createTestAddress(
ctx: APIRequestContext,
name: string,
domain: string = TEST_DOMAIN
domain: string = TEST_DOMAIN,
workerUrl: string = WORKER_URL,
): Promise<{ jwt: string; address: string; address_id: number }> {
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 },
});
if (!res.ok()) {
@@ -75,7 +78,7 @@ export async function seedTestMail(
`--${boundary}--`,
].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 },
});
if (!res.ok()) {
@@ -164,14 +167,20 @@ export function onMailpitMessage(
ws.on('open', () => readyResolve());
ws.on('message', (data: WebSocket.Data) => {
try {
const event = JSON.parse(data.toString());
if (event.Type === 'new' && predicate(event.Data)) {
clearTimeout(timer);
ws.close();
if (!settled) { settled = true; resolve(event.Data); }
}
} catch { /* ignore parse errors */ }
// Mailpit may batch multiple newline-separated events in one message.
for (const line of data.toString().split('\n')) {
if (settled) return;
try {
const event = JSON.parse(line);
if (event.Type === 'new' && predicate(event.Data)) {
clearTimeout(timer);
settled = true;
ws.close();
resolve(event.Data);
return;
}
} catch { /* ignore malformed events without dropping the remaining ones */ }
}
});
ws.on('close', () => {
+19
View File
@@ -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 };
+14 -3
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -7,22 +7,30 @@ keep_vars = true
[vars]
PREFIX = "TMP"
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"
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
BLACK_LIST = ""
ENABLE_USER_CREATE_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
DEFAULT_SEND_BALANCE = 10
NO_LIMIT_SEND_ROLE = "case-role"
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_USER_ROLE = "admin"
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
CLEANUP_BATCH_SIZE = 10
SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
"""
@@ -35,3 +43,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email"
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+6 -2
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_env_off"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -13,13 +13,14 @@ JWT_SECRET = "e2e-test-secret-key-env-off"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = false
DISABLE_ADDRESS_UPDATED_AT = true
ENABLE_REDEEM_CODE = false
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
@@ -32,3 +33,6 @@ id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email-env-off"
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+4 -2
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_gzip"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -18,7 +18,6 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
ENABLE_MAIL_GZIP = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
@@ -32,3 +31,6 @@ id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email-gzip"
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"
main = "src/worker.ts"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -23,7 +23,6 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
"""
@@ -36,3 +35,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
binding = "DB"
database_name = "e2e-temp-email"
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"
+83 -120
View File
@@ -6,14 +6,14 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.7.8",
"nodemailer": "^10.0.0"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
"@playwright/test": "1.63.0",
"@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3"
}
},
"node_modules/@pinojs/redact": {
@@ -23,39 +23,29 @@
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/ws": {
@@ -69,13 +59,13 @@
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"version": "5.4.16",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libmime": "5.4.3",
"libqp": "2.1.1"
}
},
@@ -89,33 +79,28 @@
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz",
"integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
"node": ">=18.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"node_modules/hono": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
"node": ">=16.9.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -129,38 +114,25 @@
}
},
"node_modules/imapflow": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
"version": "1.7.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"@zone-eu/mailsplit": "5.4.16",
"encoding-japanese": "2.3.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libmime": "5.4.3",
"libqp": "2.1.1",
"nodemailer": "8.0.5",
"pino": "10.3.1",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
"socks": "2.8.9"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -173,29 +145,17 @@
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"encoding-japanese": "2.3.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
@@ -203,12 +163,12 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
"node": ">=20.0.0"
}
},
"node_modules/on-exit-leak-free": {
@@ -258,41 +218,38 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
@@ -346,12 +303,12 @@
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
@@ -378,28 +335,34 @@
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
"real-require": "^1.0.0"
},
"engines": {
"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": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
+7 -6
View File
@@ -3,17 +3,18 @@
"private": true,
"type": "module",
"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:down": "docker compose down -v"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
"@playwright/test": "1.63.0",
"@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3"
},
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.7.8",
"nodemailer": "^10.0.0"
}
}
+38
View File
@@ -59,6 +59,21 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
done
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 ..."
for i in $(seq 1 60); do
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
@@ -72,6 +87,21 @@ for i in $(seq 1 60); do
sleep 1
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 ..."
for i in $(seq 1 30); do
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"
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"
npm run test:unit
exec npx playwright test "$@"
+30 -2
View File
@@ -48,7 +48,7 @@ async function receiveGzipMail(
`--${boundary}--`,
].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 },
});
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',
].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 },
});
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 }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
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);
}
});
});
}
+176
View File
@@ -0,0 +1,176 @@
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}`;
}
test('password login tokens renew below seven days; legacy credentials do not renew', async ({ request }) => {
const mailbox = await createTestAddress(request, 'password-renewal');
const day = 24 * 60 * 60;
const now = Math.floor(Date.now() / 1000);
const identity = { address: mailbox.address, address_id: mailbox.address_id };
try {
for (const remainingDays of [8, 6]) {
const token = signToken({
...identity, type: 'address_password_login',
iat: now - (30 - remainingDays) * day, exp: now + remainingDays * day,
});
const response = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.ok()).toBe(true);
const settings = await response.json();
expect(settings.type).toBe('address_password_login');
if (remainingDays > 7) {
expect(settings.new_address_token).toBeNull();
continue;
}
expect(settings.new_address_token).toEqual(expect.any(String));
const renewed = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${settings.new_address_token}` },
});
expect(renewed.ok()).toBe(true);
const renewedSettings = await renewed.json();
expect(renewedSettings).toMatchObject({ ...identity, type: 'address_password_login', new_address_token: null });
expect(renewedSettings.exp - renewedSettings.iat).toBe(30 * day);
}
const legacy = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
expect(legacy.ok()).toBe(true);
expect(await legacy.json()).toMatchObject({ ...identity, new_address_token: null });
for (const claims of [
{ type: 'telegram_binding' },
{ type: 'unknown' },
{ type: 'address_password_login' },
{ type: 'address_password_login', iat: now - 30 * day, exp: now - 1 },
{ type: 'address_password_login', iat: now, exp: now + 31 * day },
]) {
const response = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${signToken({ ...identity, ...claims })}` },
});
expect(response.status(), JSON.stringify(claims)).toBe(401);
}
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
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);
}
});
+44
View File
@@ -30,6 +30,10 @@ test.describe('Address Password Login', () => {
headers: { Authorization: `Bearer ${loginBody.jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.type).toBe('address_password_login');
expect(settings.exp - settings.iat).toBe(30 * 24 * 60 * 60);
expect(settings.new_address_token).toBeNull();
} finally {
await deleteAddress(request, jwt);
}
@@ -164,4 +168,44 @@ test.describe('Address Password Login', () => {
await deleteAddress(request, jwt);
}
});
test('users can reset a mailbox password only while it is bound to them', async ({ request }) => {
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-user-reset');
const email = `pwd-reset-user-${Date.now()}@test.example.com`;
const password = hashPassword('password-reset-user');
const newPassword = hashPassword('replacement-mailbox-password');
try {
const enable = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: { enable: true, enableMailVerify: false },
});
expect(enable.ok()).toBe(true);
const register = await request.post(`${WORKER_URL}/user_api/register`, { data: { email, password } });
expect(register.ok()).toBe(true);
const login = await request.post(`${WORKER_URL}/user_api/login`, { data: { email, password } });
expect(login.ok()).toBe(true);
const { jwt: userJwt } = await login.json();
const headers = { 'x-user-token': userJwt };
const reset = (value = newPassword) => request.post(`${WORKER_URL}/user_api/address/${address_id}/reset_password`, {
headers, data: { new_password: value },
});
expect((await reset()).status()).toBe(403);
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: { ...headers, Authorization: `Bearer ${jwt}` },
});
expect(bind.ok()).toBe(true);
expect((await reset('plaintext')).status()).toBe(400);
expect((await reset()).ok()).toBe(true);
const mailboxLogin = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: newPassword },
});
expect(mailboxLogin.ok()).toBe(true);
const unbind = await request.post(`${WORKER_URL}/user_api/unbind_address`, {
headers, data: { address_id },
});
expect(unbind.ok()).toBe(true);
expect((await reset()).status()).toBe(403);
} finally {
await deleteAddress(request, jwt);
}
});
});
+73
View File
@@ -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);
});
});
+1 -1
View File
@@ -175,7 +175,7 @@ async function seedTestMailWithReply(
text,
].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 },
});
if (!res.ok()) {
+104
View File
@@ -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);
}
});
});
@@ -65,7 +65,7 @@ test.describe('Email forward domain normalization', () => {
`Forward domain normalization test`,
].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, raw },
});
expect(res.ok()).toBe(true);
@@ -118,7 +118,7 @@ test.describe('Email forward domain normalization', () => {
`Forward domain boundary test`,
].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, raw },
});
expect(res.ok()).toBe(true);
@@ -161,7 +161,7 @@ test.describe('Email forward domain normalization', () => {
`Forward catch-all domain test`,
].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 },
});
expect(res.ok()).toBe(true);
+3
View File
@@ -19,6 +19,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
}
});
expect(res.status()).toBe(401);
expect(await res.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID', message: expect.any(String) });
});
});
@@ -43,6 +44,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
}
});
expect(res.status()).toBe(401);
expect(await res.json()).toMatchObject({ code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: expect.any(String) });
});
test('empty password returns 401', async ({ request }) => {
@@ -53,6 +55,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
}
});
expect(res.status()).toBe(401);
expect(await res.json()).toMatchObject({ code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: expect.any(String) });
});
});
+1 -1
View File
@@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => {
`--${boundary}--`,
].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 },
});
expect(seedRes.ok()).toBe(true);
+45
View File
@@ -1,6 +1,8 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test.describe('Mail Detail', () => {
test('fetch a single mail by ID', async ({ request }) => {
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();
});
});
+108
View File
@@ -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);
}
});
});
+312
View File
@@ -0,0 +1,312 @@
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.json()).toMatchObject({
code: credentials.name === 'expired Admin role token'
? 'AUTH_USER_ACCESS_TOKEN_EXPIRED' : 'AUTH_ADMIN_CREDENTIAL_INVALID',
});
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);
}
}
});
}
});
+336
View File
@@ -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);
});
});
+628
View File
@@ -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);
}
});
});
+29
View File
@@ -51,4 +51,33 @@ test.describe('Send Mail via SMTP', () => {
// Cleanup
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);
}
});
});
+44 -12
View File
@@ -1,13 +1,15 @@
import { test, expect } from '@playwright/test';
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
const MANUAL_BASE_DOMAIN = 'manual.example.com';
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_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;
let originalCreateAddressStoredEnabled: 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);
const uniqueName = `subdomain-default-${Date.now()}`;
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(await res.text()).toContain('Invalid domain');
expect(res.ok()).toBe(true);
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
});
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');
});
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.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
+1 -1
View File
@@ -18,7 +18,7 @@ test.describe('Telegram AI extraction rendering', () => {
'Telegram AI extraction realtime body',
].join('\r\n');
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: {
from: 'sender@test.example.com',
to: address,
+147
View File
@@ -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}` },
});
}
}
});
+65
View File
@@ -0,0 +1,65 @@
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.json()).toEqual({
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
message: '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.json()).toEqual({
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
message: '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);
}
}
}
});
});
+415
View File
@@ -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,
});
}
}
});
});
+2 -2
View File
@@ -95,7 +95,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
`Webhook trigger test body`,
].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,
@@ -157,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
`Should not trigger webhook`,
].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 },
});
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');
});
+79
View File
@@ -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();
}
}
});
+387
View File
@@ -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);
});
+134
View File
@@ -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);
});
});
+417
View File
@@ -0,0 +1,417 @@
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, needAuth: true },
{ name: 'account credential', path: '/user_api/settings', site: false, admin: false, needAuth: true },
{ name: 'site password', path: '/api/settings', site: true, admin: false, needAuth: true },
{ name: 'site password on admin API', path: '/admin/db_version', site: true, admin: false, needAuth: true },
{ name: 'admin password', path: '/admin/db_version', site: false, admin: true, needAuth: true },
{ name: 'admin login', path: '/open_api/admin_login', site: false, admin: true, needAuth: false },
{ name: 'site password after authentication', path: '/api/settings', site: true, admin: false, needAuth: false },
{ name: 'site password on admin API after authentication', path: '/admin/db_version', site: true, admin: false, needAuth: false },
]) {
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' } }
: scenario.admin
? { status: 401, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: 'Admin password required' } }
: { status: 401, body: 'Invalid credential' }));
const result = await page.evaluate(async ({ path, needAuth }) => {
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 = needAuth;
state.showAuth.value = false;
state.showAdminAuth.value = false;
try {
await api.fetch(path);
} catch {
return { site: state.showAuth.value, admin: state.showAdminAuth.value };
}
}, { path: scenario.path, needAuth: scenario.needAuth });
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, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: '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));
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();
}
}
});
});
+137
View File
@@ -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();
}
}
});
});
+49
View File
@@ -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
View File
@@ -1,3 +1,4 @@
VITE_API_BASE=https://temp-email-api.xxx.xxx
VITE_DEFAULT_LANG=zh
VITE_CF_WEB_ANALY_TOKEN=
VITE_IS_TELEGRAM=false
+1
View File
@@ -14,6 +14,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/logo.png" sizes="any">
<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>
</head>
+13 -13
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.9.0",
"version": "1.12.0",
"private": true,
"type": "module",
"scripts": {
@@ -24,36 +24,36 @@
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@simplewebauthn/browser": "^13.3.0",
"@unhead/vue": "^2.1.15",
"@vueuse/core": "^14.3.0",
"@unhead/vue": "^2.1.17",
"@vueuse/core": "^14.4.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.16.1",
"dompurify": "^3.4.7",
"axios": "^1.20.0",
"dompurify": "^3.4.15",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.4",
"naive-ui": "^2.45.3",
"postal-mime": "^2.7.6",
"vooks": "^0.2.12",
"vue": "^3.5.35",
"vue": "^3.5.42",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.4.4",
"vue-i18n": "^11.4.10",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.7",
"@vitejs/plugin-vue": "^6.0.8",
"jsdom": "^28.1.0",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.3.5",
"vite": "^7.3.6",
"vite-plugin-pwa": "^1.3.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^3.2.6",
"vitest": "^4.1.11",
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1",
"wrangler": "^4.96.0"
"wrangler": "^4.129.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+1236 -1264
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -3,7 +3,7 @@ import {
darkTheme,
} from 'naive-ui'
import { computed, onMounted, watchEffect } from 'vue'
import { useScript } from '@unhead/vue'
import { useHead } from '@unhead/vue'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
@@ -12,12 +12,13 @@ import Footer from './views/Footer.vue';
import { api } from './api'
import { getNaiveLocaleConfig } from './i18n/naive-locale'
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
import { APP_CONFIG } from './config'
const {
isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState()
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
const adClient = APP_CONFIG.GOOGLE_AD_CLIENT;
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT;
const { locale } = useI18n({ useScope: 'global' });
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
@@ -31,12 +32,13 @@ watchEffect(() => {
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
})
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
useHead({
script: [{
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: 'anonymous',
}],
})
}
@@ -47,7 +49,7 @@ onMounted(async () => {
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
if (token && !exist) {
@@ -66,7 +68,7 @@ onMounted(async () => {
// check if telegram is enabled
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
const enableTelegram = APP_CONFIG.IS_TELEGRAM;
if (
(typeof enableTelegram === 'boolean' && enableTelegram === true)
||
+71
View File
@@ -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')
})
})
+15
View File
@@ -0,0 +1,15 @@
export const ErrorCode = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
AUTH_SITE_PASSWORD_INVALID: 'AUTH_SITE_PASSWORD_INVALID',
AUTH_ADMIN_CREDENTIAL_INVALID: 'AUTH_ADMIN_CREDENTIAL_INVALID',
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
isSiteAuthError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_SITE_PASSWORD_INVALID,
isUserAccessTokenError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED,
isAdminAuthError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_ADMIN_CREDENTIAL_INVALID,
};
+51 -19
View File
@@ -1,26 +1,44 @@
import { useGlobalState } from '../store'
import { h } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import axios from 'axios'
import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
import { sanitizeHtml } from '../utils/sanitize-html'
import { APP_CONFIG } from '../config'
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
import { ErrorCode } from './error-codes'
import { updateLocalAddressCache } from '../utils/local-address-cache'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const API_BASE = APP_CONFIG.API_BASE || "";
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings, announcement,
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const localAddressCache = useLocalStorage('LocalAddressCache', []);
const instance = axios.create({
baseURL: API_BASE,
timeout: 30000,
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 = {}) => {
loading.value = true;
const showLoading = options.showLoading !== false;
if (showLoading) loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
@@ -41,32 +59,33 @@ const apiFetch = async (path, options = {}) => {
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
const adminAuthHeader = safeHeaderValue(adminAuth.value);
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
const authorizationHeader = safeBearerHeader(jwt.value);
const authorizationHeader = safeBearerHeader(options.addressJwt ?? jwt.value);
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
const response = await instance.request(path, {
const initialResponse = await instance.request(path, {
method: options.method || 'GET',
data: options.body || null,
headers,
});
if (response.status === 401 && path.startsWith("/admin")) {
const response = await interceptResponse(path, initialResponse);
if (ErrorCode.isAdminAuthError(response)) {
showAdminAuth.value = true;
}
if (response.status === 401 && openSettings.value.needAuth) {
if (ErrorCode.isSiteAuthError(response)) {
showAuth.value = true;
}
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;
return data;
} catch (error) {
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;
} finally {
loading.value = false;
if (showLoading) loading.value = false;
}
}
@@ -98,6 +117,7 @@ const getOpenSettings = async (message, notification) => {
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableMailReadStatus: res["enableMailReadStatus"] === true,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
@@ -106,7 +126,10 @@ const getOpenSettings = async (message, notification) => {
isS3Enabled: res["isS3Enabled"] || false,
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
enableAddressPassword: res["enableAddressPassword"] || false,
addressPasswordLoginOnly: res["addressPasswordLoginOnly"] === true,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
enableRedeemCode: res["enableRedeemCode"] || false,
redeemCodeUrl: res["redeemCodeUrl"] || "",
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
@@ -123,7 +146,7 @@ const getOpenSettings = async (message, notification) => {
notification.info({
content: () => {
return h("div", {
innerHTML: announcement.value
innerHTML: sanitizeHtml(announcement.value)
});
}
});
@@ -136,18 +159,27 @@ const getOpenSettings = async (message, notification) => {
}
const getSettings = async () => {
let addressToken = jwt.value;
try {
if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') {
return "";
if (!safeHeaderValue(addressToken)) return;
const res = await apiFetch('/api/settings', { addressJwt: addressToken });
if (jwt.value !== addressToken) return;
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, addressToken, res);
settings.value = res;
const renewedAddressToken = res.new_address_token;
if (!renewedAddressToken) return;
try {
const renewedSettings = await apiFetch('/api/settings', { addressJwt: renewedAddressToken });
if (jwt.value !== addressToken) return;
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, renewedAddressToken, renewedSettings);
addressToken = renewedAddressToken;
jwt.value = renewedAddressToken;
settings.value = renewedSettings;
} catch (error) {
console.error('Failed to renew mailbox JWT', error);
}
const res = await apiFetch("/api/settings");;
settings.value = {
address: res["address"],
auto_reply: res["auto_reply"],
send_balance: res["send_balance"],
};
} finally {
settings.value.fetched = true;
if (jwt.value === addressToken) settings.value.fetched = true;
}
}
@@ -0,0 +1,77 @@
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;
const isCurrentSession = ({ headers }) =>
safeHeaderValue(headers.get('x-user-token')) === safeHeaderValue(userJwt.value)
&& safeHeaderValue(headers.get('Authorization')) === safeBearerHeader(jwt.value);
const matches = (path, response) => {
if (!ErrorCode.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(openSettings.addressPasswordLoginOnly ? 'passwordOnlyTip' : 'tip') }}
</n-alert>
<section class="credential-panel">
<h3 class="credential-title">{{ t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : '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 v-if="!openSettings.addressPasswordLoginOnly" 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 v-if="!openSettings.addressPasswordLoginOnly" 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>
@@ -1,9 +1,13 @@
<script setup>
import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import AddressCredentialContent from './AddressCredentialContent.vue'
import { useGlobalState } from '../store'
const { openSettings } = useGlobalState()
const props = defineProps({
show: {
type: Boolean,
@@ -24,299 +28,17 @@ const props = defineProps({
})
const emit = defineEmits(['update:show'])
const { openSettings, auth } = useGlobalState()
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
const message = useMessage()
const { t } = useScopedI18n('components.AddressCredentialModal')
const modalShow = computed({
get: () => props.show,
set: (value) => emit('update:show', value),
})
const configuredApiBaseUrl = import.meta.env.VITE_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>
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
<n-modal v-model:show="modalShow" preset="card" :title="t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'title')"
style="width: min(760px, calc(100vw - 32px));">
<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 class="credential-field" v-if="address">
<span class="credential-label">{{ t('currentAddress') }}</span>
<div class="credential-copy-row">
<code 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 class="credential-code">{{ jwt }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field" v-if="addressPassword">
<span class="credential-label">{{ t('addressPassword') }}</span>
<code 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>
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
</n-modal>
</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>
+17 -30
View File
@@ -8,6 +8,7 @@ import { Copy } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getCachedAddresses } from '../utils/local-address-cache'
const props = defineProps({
showCopy: {
@@ -28,6 +29,8 @@ const {
} = useGlobalState()
const { t } = useScopedI18n('components.AddressSelect')
const { t: loginT } = useScopedI18n('views.common.Login')
const { t: localAddressT } = useScopedI18n('views.index.LocalAddress')
const addressOptions = ref([])
const addressValue = ref(null)
@@ -45,21 +48,6 @@ const formatAddressLabel = (address) => {
return address.replace('@' + domain, `@${domainLabel}`);
}
const parseJwtAddress = (curJwt) => {
try {
const payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")
)
)
);
return payload.address;
} catch (e) {
return null;
}
}
const getOptionValue = (key, scope, payload, address) => {
if (optionValueMap.has(key)) {
const cached = optionValueMap.get(key)
@@ -74,18 +62,17 @@ const getOptionValue = (key, scope, payload, address) => {
}
const buildLocalOptions = (excludeAddresses = new Set()) => {
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
localAddressCache.value.push(jwt.value)
}
const children = localAddressCache.value
.map((curJwt) => {
const address = parseJwtAddress(curJwt);
if (!address) return null;
const children = getCachedAddresses(localAddressCache.value)
.map(({ token, address, type }, index) => {
if (excludeAddresses.has(address)) return null;
const label = formatAddressLabel(address);
const key = `local:${curJwt}`;
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
if (settings.value.address && address === settings.value.address) {
const isPasswordLogin = type === 'address_password_login';
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
const label = address
? `${formatAddressLabel(address)} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
: localAddressT('savedMailbox', { index: index + 1 });
const key = `local:${token}`;
const option = { label, value: getOptionValue(key, 'local', token, address), address };
if (token === jwt.value) {
addressValue.value = option.value;
}
return option;
@@ -97,7 +84,7 @@ const buildLocalOptions = (excludeAddresses = new Set()) => {
const buildUserOptions = async () => {
const children = [];
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 || []) {
const address = row.address || row.name;
if (!address) continue;
@@ -153,7 +140,7 @@ const refreshAddressOptions = async () => {
if (userJwt.value) {
const userChildren = await buildUserOptions();
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 localChildren = buildLocalOptions(userAddressSet);
@@ -207,7 +194,7 @@ onMounted(async () => {
await refreshAddressOptions();
});
watch([userJwt, isTelegram, () => settings.value.address], async () => {
watch([userJwt, isTelegram, localAddressCache, () => settings.value.address, () => openSettings.value.addressPasswordLoginOnly], async () => {
await refreshAddressOptions();
});
</script>
@@ -215,7 +202,7 @@ watch([userJwt, isTelegram, () => settings.value.address], async () => {
<template>
<n-flex class="address-row" align="center" justify="center" :wrap="true">
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
:loading="addressLoading" :placeholder="t('selectMailbox')" @update:value="onAddressChange"
class="address-select" />
<slot name="actions" />
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
+210 -21
View File
@@ -55,12 +55,20 @@ const props = defineProps({
default: false,
required: false
},
enableMailReadStatus: {
type: Boolean,
default: false
},
updateMailReadStatus: {
type: Function,
default: () => { }
},
})
const localFilterKeyword = ref('')
const {
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
@@ -71,6 +79,12 @@ const count = ref(0)
const page = ref(1)
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)
const data = computed(() => {
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(() => {
if (!curMail.value) return false
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)
if (currentIndex > 0) {
curMail.value = data.value[currentIndex - 1]
openMail(data.value[currentIndex - 1])
} else if (page.value > 1) {
page.value--
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[data.value.length - 1]
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)
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) {
page.value++
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[0]
openMail(data.value[0])
}
}
}
@@ -183,7 +220,7 @@ const refresh = async () => {
count.value = totalCount;
}
curMail.value = null;
if (!isMobile.value && data.value.length > 0) {
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
@@ -199,12 +236,17 @@ const backFirstPageAndRefresh = async () => {
await refresh();
}
const clickRow = async (row) => {
const clickRow = (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
curMail.value = row;
return;
}
curMail.value = row;
if (mailListView.value && curMail.value?.id === row.id) {
curMail.value = null;
return;
}
openMail(row);
};
@@ -375,13 +417,18 @@ onBeforeUnmount(() => {
clearable />
</n-space>
</div>
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<n-split class="left" direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
: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>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
@@ -412,15 +459,25 @@ onBeforeUnmount(() => {
</template>
<template #2>
<div v-if="curMail" style="margin: 8px;">
<n-flex justify="space-between">
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevMail') }}
</n-button>
<n-flex justify="space-between" align="center">
<n-space :wrap="false" align="center">
<n-button v-if="mailListView" @click="curMail = null" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ 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">
<template #icon>
<n-icon>
@@ -435,6 +492,7 @@ onBeforeUnmount(() => {
style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
@@ -446,6 +504,48 @@ onBeforeUnmount(() => {
</n-card>
</template>
</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 class="left" v-else>
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
@@ -468,7 +568,8 @@ onBeforeUnmount(() => {
</div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-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">
<template #description>
<n-tag type="info">
@@ -499,6 +600,7 @@ onBeforeUnmount(() => {
<n-card :bordered="false" embedded style="overflow: auto;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" />
</n-card>
@@ -555,8 +657,95 @@ onBeforeUnmount(() => {
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 {
white-space: pre-wrap;
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>
@@ -1,14 +1,15 @@
<script setup>
import { ref } from "vue";
import { ref, computed, watch } from "vue";
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 AiExtractInfo from "./AiExtractInfo.vue";
import { getDownloadEmlUrl } from '../utils/email-parser';
import { blockRemoteContent } from '../utils/remote-content-policy';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
const { t } = useScopedI18n('components.MailContentRenderer')
@@ -33,6 +34,10 @@ const props = defineProps({
type: Boolean,
default: false
},
enableMailReadStatus: {
type: Boolean,
default: false
},
// props
onDelete: {
type: Function,
@@ -49,6 +54,10 @@ const props = defineProps({
onSaveToS3: {
type: Function,
default: () => { }
},
onUpdateMailReadStatus: {
type: Function,
default: () => { }
}
});
@@ -56,8 +65,29 @@ const showTextMail = ref(preferShowTextMail.value);
const showAttachments = ref(false);
const curAttachments = ref([]);
const attachmentLoding = ref(false);
const readStatusUpdating = 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 = () => {
props.onDelete();
};
@@ -75,6 +105,15 @@ const handleForward = () => {
props.onForward();
};
const handleUpdateMailReadStatus = async () => {
readStatusUpdating.value = true;
try {
await props.onUpdateMailReadStatus();
} finally {
readStatusUpdating.value = false;
}
};
const handleSaveToS3 = async (filename, blob) => {
attachmentLoding.value = true;
@@ -117,6 +156,11 @@ const handleSaveToS3 = async (filename, blob) => {
{{ t('attachments') }}
</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'"
:href="getDownloadEmlUrl(mail.raw)">
<template #icon>
@@ -149,17 +193,32 @@ const handleSaveToS3 = async (filename, blob) => {
</template>
{{ t('fullscreen') }}
</n-button>
</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 提取信息 -->
<AiExtractInfo :metadata="mail.metadata" />
<!-- 邮件内容 -->
<div class="mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-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>
@@ -168,9 +227,9 @@ const handleSaveToS3 = async (filename, blob) => {
<n-drawer-content :title="mail.subject" closable>
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-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>
</n-drawer-content>
</n-drawer>
@@ -215,6 +274,11 @@ const handleSaveToS3 = async (filename, blob) => {
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 {
margin-top: 10px;
flex: 1;
+26 -2
View File
@@ -210,8 +210,13 @@ onMounted(async () => {
</n-button>
</n-space>
</div>
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<n-split direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #resize-trigger>
<div class="split-handle">
<div class="split-handle__grip" />
</div>
</template>
<template #1>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
@@ -379,4 +384,23 @@ pre {
white-space: pre-wrap;
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>
+39
View File
@@ -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
+79 -29
View File
@@ -1,14 +1,44 @@
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.Admin.account": "Konto",
"views.Index.accountSettings": "Kontoeinstellungen",
"views.Admin.account_settings": "Kontoeinstellungen",
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
"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",
@@ -22,7 +52,7 @@ export const deMessages = {
"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.address": "Adresse",
"components.AddressSelect.selectMailbox": "Postfach auswählen",
"views.user.AddressManagement.address": "Adresse",
"views.index.TelegramAddress.address": "Adresse",
"views.admin.SenderAccess.address": "Adresse",
@@ -58,10 +88,10 @@ export const deMessages = {
"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 diese E-Mail 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 wirklich dein Konto und alle zugehörigen E-Mails löschen?",
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"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?",
@@ -74,6 +104,7 @@ export const deMessages = {
"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",
@@ -94,10 +125,10 @@ export const deMessages = {
"views.admin.AccountSettings.cancel": "Abbrechen",
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
"views.user.AddressManagement.openMailbox": "Postfach öffnen",
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
"views.index.AccountSettings.changePassword": "Passwort ä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",
@@ -133,8 +164,8 @@ export const deMessages = {
"views.index.SimpleIndex.copyAddress": "Kopieren",
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
"views.Footer.copyright": "Urheberrecht",
"views.Admin.account_create": "Konto erstellen",
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
"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",
@@ -169,8 +200,8 @@ export const deMessages = {
"views.admin.UserManagement.delete": "Löschen",
"views.admin.AccountSettings.delete_rule": "Löschen",
"views.admin.Maintenance.deleteCustomSql": "Löschen",
"views.index.AccountSettings.deleteAccount": "Konto löschen",
"views.admin.Account.deleteAccount": "Konto 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",
@@ -241,7 +272,7 @@ export const deMessages = {
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
"views.common.Login.help": "Hilfe",
"views.Header.home": "Startseite",
"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)",
@@ -281,7 +312,7 @@ export const deMessages = {
"views.user.UserSettings.logout": "Abmelden",
"views.user.BindAddress.logout": "Abmelden",
"views.Admin.logout": "Abmelden",
"views.index.AccountSettings.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",
@@ -290,7 +321,7 @@ export const deMessages = {
"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.mailbox": "Posteingang",
"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",
@@ -299,7 +330,10 @@ export const deMessages = {
"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": "Größe der Mailbox-Aufteilung",
"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",
@@ -317,11 +351,11 @@ export const deMessages = {
"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.name": "Name",
"views.user.AddressManagement.emailAddress": "E-Mail-Adresse",
"views.index.AutoReply.name": "Name",
"views.admin.Account.name": "Name",
"views.admin.Account.emailAddress": "E-Mail-Adresse",
"views.admin.UserOauth2Settings.name": "Name",
"views.admin.UserAddressManagement.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",
@@ -330,7 +364,8 @@ export const deMessages = {
"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": "Noch kein Sendeguthaben vorhanden. Wenn der Administrator ein Standardguthaben aktiviert hat, wird es automatisch zugewiesen; andernfalls Zugriff anfordern oder den Administrator kontaktieren.",
"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",
@@ -369,6 +404,7 @@ export const deMessages = {
"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",
@@ -461,7 +497,7 @@ export const deMessages = {
"views.admin.UserAddressManagement.send_count": "Sendeanzahl",
"views.Index.sendmail": "E-Mail senden",
"views.Admin.sendMail": "E-Mail senden",
"views.index.SendMail.send_balance": "Verbleibendes Sendeguthaben",
"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",
@@ -522,7 +558,7 @@ export const deMessages = {
"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": "Dies unterscheidet sich von RANDOM_SUBDOMAIN_DOMAINS: Dieser Schalter erlaubt API-Aufrufern, benutzerdefinierte Subdomains direkt anzugeben, während die zufällige Subdomain nur bei der Erstellung automatisch erzeugt wird.",
"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",
@@ -551,13 +587,13 @@ export const deMessages = {
"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.user": "Benutzer",
"views.Header.userCenter": "Benutzerbereich",
"views.Admin.user": "Benutzer",
"components.AddressSelect.userAddresses": "Benutzeradressen",
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
"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.userLogin": "Benutzeranmeldung",
"views.index.AddressBar.userCenter": "Benutzerbereich",
"views.Admin.user_management": "Benutzerverwaltung",
"views.Admin.user_settings": "Benutzereinstellungen",
"views.User.user_settings": "Benutzereinstellungen",
@@ -578,6 +614,10 @@ export const deMessages = {
"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",
@@ -613,5 +653,15 @@ export const deMessages = {
"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"
"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"
}
+79 -29
View File
@@ -1,14 +1,44 @@
export const esMessages = {
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
"views.index.SendMail.composeMail": "Redactar correo",
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
"views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
"views.index.SendMail.recipientAddress": "Dirección del destinatario",
"views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
"views.index.SendMail.senderAddress": "Dirección del remitente",
"views.index.SendMail.senderName": "Nombre del remitente (opcional)",
"views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
"views.admin.SendMail.composeMail": "Redactar correo",
"views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
"views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
"views.admin.SendMail.recipientAddress": "Dirección del destinatario",
"views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
"views.admin.SendMail.senderAddress": "Dirección del remitente",
"views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Plan de Workers",
"views.admin.DatabaseManager.plan_placeholder": "Selecciona tu plan de Cloudflare Workers",
"views.admin.DatabaseManager.planSaved": "Plan de Workers guardado",
"views.admin.DatabaseManager.single_database_limit": "Límite de capacidad de la base de datos",
"views.admin.DatabaseManager.storage_description": "Compara el tamaño actual de la base de datos con los límites de tu plan.",
"views.admin.DatabaseManager.storage_tip": "El uso se calcula con el tamaño actual de la base de datos y el límite del plan seleccionado.",
"views.admin.DatabaseManager.storage_title": "Capacidad de almacenamiento D1",
"views.admin.DatabaseManager.storage_usage": "Uso de capacidad",
"views.admin.DatabaseManager.unavailable": "No disponible",
"views.index.SimpleIndex.mailCount": "{current} / {total} correos",
"views.admin.Statistics.activeAddressCount30days": "Cantidad de direcciones activas en 30 días",
"views.admin.Statistics.activeAddressCount7days": "Cantidad de direcciones activas en 7 días",
"views.Index.about": "Acerca de",
"views.Admin.about": "Acerca de",
"views.Header.accessHeader": "Contraseña de acceso",
"views.Admin.account": "Cuenta",
"views.Index.accountSettings": "Configuración de la cuenta",
"views.Admin.account_settings": "Configuración de la cuenta",
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
"views.Header.admin": "Consola de administración",
"views.Admin.mailbox_management": "Gestión de buzones",
"views.Admin.mailbox_list": "Buzones",
"views.Index.mailboxSettings": "Configuración del buzón",
"views.Admin.mailbox_settings": "Configuración global de buzones",
"views.index.SimpleIndex.mailboxSettings": "Configuración del buzón",
"views.index.Attachment.action": "Acción",
"views.admin.SenderAccess.action": "Acción",
"views.user.UserSettings.actions": "Acciones",
@@ -22,7 +52,7 @@ export const esMessages = {
"views.admin.Maintenance.customSqlTip": "Añade sentencias SQL DELETE personalizadas para la limpieza programada. Solo se permite una sentencia DELETE por entrada.",
"views.admin.Maintenance.addCustomSql": "Añadir SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
"components.AddressSelect.address": "Dirección",
"components.AddressSelect.selectMailbox": "Seleccionar buzón",
"views.user.AddressManagement.address": "Dirección",
"views.index.TelegramAddress.address": "Dirección",
"views.admin.SenderAccess.address": "Dirección",
@@ -58,10 +88,10 @@ export const esMessages = {
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?",
"views.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este buzón?",
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar tu cuenta y todos sus correos?",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección de correo y todos sus correos? Esto no elimina tu cuenta de usuario.",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
@@ -74,6 +104,7 @@ export const esMessages = {
"components.AiExtractInfo.authLink": "Enlace de autenticación",
"views.admin.Maintenance.autoCleanup": "Limpieza automática",
"components.MailBox.autoRefresh": "Actualización automática",
"components.MailBox.backToList": "Volver a la lista",
"views.common.Appearance.autoRefreshInterval": "Intervalo de actualización automática (s)",
"views.Index.auto_reply": "Respuesta automática",
"views.index.AutoReply.autoReply": "Respuesta automática",
@@ -94,10 +125,10 @@ export const esMessages = {
"views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
"views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
"views.user.AddressManagement.openMailbox": "Abrir buzón",
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.AccountSettings.changePassword": "Cambiar contraseña",
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
"views.admin.UserManagement.changeRole": "Cambiar Rol",
"components.SendBox.showCode": "Cambiar vista del código original",
"views.admin.Telegram.status": "Ver estado",
@@ -133,8 +164,8 @@ export const esMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Error al copiar",
"views.Footer.copyright": "Derechos de autor",
"views.Admin.account_create": "Crear cuenta",
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
"views.Admin.mailbox_create": "Crear dirección de correo",
"views.admin.CreateAccount.createEmailAddress": "Crear dirección de correo",
"views.common.Login.getNewEmail": "Crear nuevo correo",
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
@@ -169,8 +200,8 @@ export const esMessages = {
"views.admin.UserManagement.delete": "Eliminar",
"views.admin.AccountSettings.delete_rule": "Eliminar",
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
"views.admin.Account.deleteAccount": "Eliminar cuenta",
"views.index.AccountSettings.deleteMailbox": "Eliminar buzón",
"views.admin.Account.deleteMailbox": "Eliminar buzón",
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
@@ -241,7 +272,7 @@ export const esMessages = {
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
"views.common.Login.help": "Ayuda",
"views.Header.home": "Inicio",
"views.Header.mailbox": "Buzón",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
@@ -281,7 +312,7 @@ export const esMessages = {
"views.user.UserSettings.logout": "Cerrar sesión",
"views.user.BindAddress.logout": "Cerrar sesión",
"views.Admin.logout": "Cerrar sesión",
"views.index.AccountSettings.logout": "Cerrar sesión",
"views.index.AccountSettings.logout": "Cerrar sesión de la dirección de correo actual",
"views.Admin.logoutSuccess": "Sesión cerrada correctamente",
"views.admin.UserOauth2Settings.mailAllowList": "Lista blanca de direcciones de correo",
"views.admin.UserSettings.mailAllowList": "Lista blanca de direcciones de correo",
@@ -290,7 +321,7 @@ export const esMessages = {
"views.admin.Account.addressCredential": "Credencial de dirección de correo",
"views.admin.CreateAccount.addressCredential": "Credencial de dirección de correo",
"views.index.AddressBar.fetchAddressError": "La credencial del correo no es válida o la cuenta no existe; también puede ser un problema de red. Inténtalo más tarde.",
"views.Index.mailbox": "Bandeja de entrada",
"views.Index.inbox": "Bandeja de entrada",
"views.User.user_mail_box_tab": "Bandeja de entrada",
"views.user.AddressManagement.mail_count": "Cantidad de correos",
"views.admin.Account.mail_count": "Cantidad de correos",
@@ -299,7 +330,10 @@ export const esMessages = {
"views.index.SimpleIndex.deleteSuccess": "Correo eliminado correctamente",
"views.user.UserLogin.cannotForgotPassword": "La verificación por correo o el registro está desactivado; no se puede restablecer la contraseña. Contacta con el administrador.",
"views.Admin.mailWebhook": "Webhook de correo",
"views.common.Appearance.mailboxSplitSize": "Tamaño de división del buzón",
"views.common.Appearance.mailboxSplitSize": "Ancho de la lista izquierda en la vista de buzón de dos columnas",
"views.common.Appearance.mailListView": "Vista de lista del buzón de ancho completo",
"views.common.Appearance.mailListPreviewLineClamp": "Líneas de vista previa del cuerpo",
"views.common.Appearance.off": "Desactivado",
"views.index.SimpleIndex.refreshSuccess": "Correos actualizados correctamente",
"views.Admin.unknow": "Correos con destinatario desconocido",
"views.Admin.maintenance": "Mantenimiento",
@@ -317,11 +351,11 @@ export const esMessages = {
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
"views.admin.Account.multiDelete": "Eliminar en lote",
"views.user.AddressManagement.name": "Nombre",
"views.user.AddressManagement.emailAddress": "Dirección de correo",
"views.index.AutoReply.name": "Nombre",
"views.admin.Account.name": "Nombre",
"views.admin.Account.emailAddress": "Dirección de correo",
"views.admin.UserOauth2Settings.name": "Nombre",
"views.admin.UserAddressManagement.name": "Nombre",
"views.admin.UserAddressManagement.emailAddress": "Dirección de correo",
"views.admin.Maintenance.sqlName": "Nombre",
"views.index.AccountSettings.newPassword": "Nueva contraseña",
"views.admin.Account.newPassword": "Nueva contraseña",
@@ -330,7 +364,8 @@ export const esMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de direcciones sin límite de saldo",
"views.index.SimpleIndex.noMails": "No se encontraron correos",
"views.admin.RoleAddressConfig.noRolesAvailable": "No hay roles disponibles en la configuración del sistema",
"views.index.SendMail.requestAccessTip": "Todavía no hay saldo de envío. Si el administrador activó un saldo por defecto, se asignará automáticamente; si no, solicita acceso o contacta con el administrador.",
"views.index.SendMail.requestAccessTip": "El permiso y el saldo de envío se gestionan por separado para cada dirección. La dirección actual, {address}, no tiene saldo disponible. Solicita permiso para esta dirección o contacta con el administrador.",
"views.index.SendMail.requestSuccess": "Permiso de envío solicitado para la dirección actual",
"components.SendBox.emptySent": "No hay correos enviados",
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
@@ -369,6 +404,7 @@ export const esMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
"views.admin.CreateAccount.generateName": "Generar nombre aleatorio",
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
@@ -461,7 +497,7 @@ export const esMessages = {
"views.admin.UserAddressManagement.send_count": "Cantidad enviada",
"views.Index.sendmail": "Enviar correo",
"views.Admin.sendMail": "Enviar correo",
"views.index.SendMail.send_balance": "Saldo restante de envío",
"views.index.SendMail.send_balance": "Saldo de envío de la dirección actual",
"views.admin.Statistics.sendMailCount": "Cantidad de correos enviados",
"views.admin.AccountSettings.send_mail_limit": "Límite de envío",
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificación",
@@ -522,7 +558,7 @@ export const esMessages = {
"views.index.LocalAddress.tip": "Estas direcciones se guardan en tu navegador y podrían perderse si borras la caché.",
"views.admin.UserOauth2Settings.tip": "El inicio de sesión de terceros usará automáticamente el correo del usuario para registrar una cuenta (el mismo correo se considera la misma cuenta). También puedes establecer la contraseña con “olvidé mi contraseña”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Se aplica a todos los canales de envío. Usa -1 para ilimitado y 0 para bloquear el envío.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Esto es diferente de RANDOM_SUBDOMAIN_DOMAINS: este interruptor permite indicar subdominios personalizados; el subdominio aleatorio solo genera uno al crear.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS ya permite subdominios aleatorios o manuales en los dominios base listados. Este interruptor permite además que la API especifique subdominios en otros dominios base permitidos.",
"views.index.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.admin.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.common.Appearance.top": "arriba",
@@ -551,13 +587,13 @@ export const esMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com",
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
"views.Header.user": "Usuario",
"views.Header.userCenter": "Centro de usuario",
"views.Admin.user": "Usuario",
"components.AddressSelect.userAddresses": "Direcciones del usuario",
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
"components.AddressSelect.linkedMailboxes": "Buzones vinculados",
"views.Admin.loginViaUserAdmin": "Cuenta de usuario (rol de administrador)",
"views.admin.Statistics.userCount": "Cantidad de usuarios",
"views.admin.UserManagement.user_email": "Correo del usuario",
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
"views.index.AddressBar.userCenter": "Centro de usuario",
"views.Admin.user_management": "Gestión de usuarios",
"views.Admin.user_settings": "Configuración de usuario",
"views.User.user_settings": "Configuración de usuario",
@@ -578,6 +614,10 @@ export const esMessages = {
"views.admin.AiExtractSettings.disabledTip": "Si está desactivado, la extracción IA procesará todas las direcciones",
"views.admin.AiExtractSettings.enableAllowListTip": "Si está activado, la extracción IA solo procesará correos enviados a direcciones permitidas",
"views.admin.CreateAccount.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdominio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Dominio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdominio personalizado",
"views.common.Login.normalSubdomain": "Dominio normal",
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
"views.admin.AiExtractSettings.allowListTip": "El comodín * coincide con cualquier carácter; p. ej., *{'@'}example.com coincide con todas las direcciones del dominio example.com",
"views.Admin.workerconfig": "Configuración del Worker",
@@ -613,5 +653,15 @@ export const esMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
"components.AddressCredentialModal.username": "Usuario"
"components.AddressCredentialModal.username": "Usuario",
"views.User.send_mail": "Enviar correo",
"views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
"views.user.UserSendBox.sendbox": "Enviados",
"components.MailContentRenderer.markAsRead": "Marcar como leído",
"components.MailContentRenderer.markAsUnread": "Marcar como no leído",
"components.MailContentRenderer.loadRemoteImages": "Cargar imágenes",
"components.MailContentRenderer.remoteImagesBlocked": "Se bloquearon {count} recursos remotos para proteger tu privacidad",
"views.user.AddressManagement.itemCount": "Total",
"views.index.SimpleIndex.readStatusUpdated": "Estado de lectura actualizado",
"views.common.Appearance.autoLoadRemoteImages": "Cargar automáticamente las imágenes externas del correo"
}
+79 -29
View File
@@ -1,14 +1,44 @@
export const jaMessages = {
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
"views.index.SendMail.composeMail": "メールを作成",
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
"views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
"views.index.SendMail.recipientAddress": "宛先メールアドレス",
"views.index.SendMail.recipientName": "宛先名(任意)",
"views.index.SendMail.senderAddress": "送信元メールアドレス",
"views.index.SendMail.senderName": "送信者名(任意)",
"views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
"views.admin.SendMail.composeMail": "メールを作成",
"views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
"views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
"views.admin.SendMail.recipientAddress": "宛先メールアドレス",
"views.admin.SendMail.recipientName": "宛先名(任意)",
"views.admin.SendMail.senderAddress": "送信元メールアドレス",
"views.admin.SendMail.senderName": "送信者名(任意)",
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Workers プラン",
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare Workers プランを選択",
"views.admin.DatabaseManager.planSaved": "Workers プランを保存しました",
"views.admin.DatabaseManager.single_database_limit": "データベース容量上限",
"views.admin.DatabaseManager.storage_description": "現在のデータベースサイズとプラン上限を比較します。",
"views.admin.DatabaseManager.storage_tip": "使用率は現在のデータベースサイズと選択したプラン上限から計算されます。",
"views.admin.DatabaseManager.storage_title": "D1 ストレージ容量",
"views.admin.DatabaseManager.storage_usage": "容量使用率",
"views.admin.DatabaseManager.unavailable": "利用不可",
"views.index.SimpleIndex.mailCount": "{current} / {total} 件のメール",
"views.admin.Statistics.activeAddressCount30days": "30日間のアクティブなアドレス数",
"views.admin.Statistics.activeAddressCount7days": "7日間のアクティブなアドレス数",
"views.Index.about": "概要",
"views.Admin.about": "概要",
"views.Header.accessHeader": "アクセス用パスワード",
"views.Admin.account": "アカウント",
"views.Index.accountSettings": "アカウント設定",
"views.Admin.account_settings": "アカウント設定",
"views.index.SimpleIndex.accountSettings": "アカウント設定",
"views.Header.admin": "管理画面",
"views.Admin.mailbox_management": "メールボックス管理",
"views.Admin.mailbox_list": "メールボックス一覧",
"views.Index.mailboxSettings": "メールボックス設定",
"views.Admin.mailbox_settings": "メールボックスのグローバル設定",
"views.index.SimpleIndex.mailboxSettings": "メールボックス設定",
"views.index.Attachment.action": "操作",
"views.admin.SenderAccess.action": "操作",
"views.user.UserSettings.actions": "操作",
@@ -22,7 +52,7 @@ export const jaMessages = {
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
"components.AddressSelect.address": "アドレス",
"components.AddressSelect.selectMailbox": "メールボックスを選択",
"views.user.AddressManagement.address": "アドレス",
"views.index.TelegramAddress.address": "アドレス",
"views.admin.SenderAccess.address": "アドレス",
@@ -58,10 +88,10 @@ export const jaMessages = {
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールボックスを削除してもよろしいですか?",
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
@@ -74,6 +104,7 @@ export const jaMessages = {
"components.AiExtractInfo.authLink": "認証リンク",
"views.admin.Maintenance.autoCleanup": "自動クリーンアップ",
"components.MailBox.autoRefresh": "自動更新",
"components.MailBox.backToList": "リストに戻る",
"views.common.Appearance.autoRefreshInterval": "自動更新間隔 (秒)",
"views.Index.auto_reply": "自動返信",
"views.index.AutoReply.autoReply": "自動返信",
@@ -94,10 +125,10 @@ export const jaMessages = {
"views.admin.AccountSettings.cancel": "キャンセル",
"components.MailBox.cancelMultiAction": "複数選択をキャンセル",
"components.SendBox.cancelMultiAction": "複数選択をキャンセル",
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
"views.user.AddressManagement.openMailbox": "メールボックスを開く",
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
"views.index.AccountSettings.changePassword": "パスワードを変更",
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
"views.admin.UserManagement.changeRole": "ロールを変更",
"components.SendBox.showCode": "元のコード表示を切り替え",
"views.admin.Telegram.status": "ステータスを確認",
@@ -133,8 +164,8 @@ export const jaMessages = {
"views.index.SimpleIndex.copyAddress": "コピー",
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
"views.Footer.copyright": "著作権",
"views.Admin.account_create": "アカウントを作成",
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
"views.Admin.mailbox_create": "メールアドレスを作成",
"views.admin.CreateAccount.createEmailAddress": "メールアドレスを作成",
"views.common.Login.getNewEmail": "新しいメールを作成",
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
@@ -169,8 +200,8 @@ export const jaMessages = {
"views.admin.UserManagement.delete": "削除",
"views.admin.AccountSettings.delete_rule": "削除",
"views.admin.Maintenance.deleteCustomSql": "削除",
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
"views.admin.Account.deleteAccount": "アカウントを削除",
"views.index.AccountSettings.deleteMailbox": "メールボックスを削除",
"views.admin.Account.deleteMailbox": "メールボックスを削除",
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
"views.admin.AccountSettings.delete_success": "削除しました",
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
@@ -241,7 +272,7 @@ export const jaMessages = {
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
"views.common.Appearance.globalTabplacement": "全体タブ位置",
"views.common.Login.help": "ヘルプ",
"views.Header.home": "ホーム",
"views.Header.mailbox": "メールボックス",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
@@ -281,7 +312,7 @@ export const jaMessages = {
"views.user.UserSettings.logout": "ログアウト",
"views.user.BindAddress.logout": "ログアウト",
"views.Admin.logout": "ログアウト",
"views.index.AccountSettings.logout": "ログアウト",
"views.index.AccountSettings.logout": "現在のメールアドレスからログアウト",
"views.Admin.logoutSuccess": "ログアウトしました",
"views.admin.UserOauth2Settings.mailAllowList": "メールアドレス許可リスト",
"views.admin.UserSettings.mailAllowList": "メールアドレス許可リスト",
@@ -290,7 +321,7 @@ export const jaMessages = {
"views.admin.Account.addressCredential": "メールアドレス資格情報",
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
"views.Index.mailbox": "受信箱",
"views.Index.inbox": "受信箱",
"views.User.user_mail_box_tab": "受信箱",
"views.user.AddressManagement.mail_count": "メール数",
"views.admin.Account.mail_count": "メール数",
@@ -299,7 +330,10 @@ export const jaMessages = {
"views.index.SimpleIndex.deleteSuccess": "メールを削除しました",
"views.user.UserLogin.cannotForgotPassword": "メール検証または登録が無効のため、パスワードを再設定できません。管理者へ連絡してください。",
"views.Admin.mailWebhook": "メールWebhook",
"views.common.Appearance.mailboxSplitSize": "メールボックス分割サイズ",
"views.common.Appearance.mailboxSplitSize": "メールボックス2カラム表示の左側リスト幅",
"views.common.Appearance.mailListView": "メールボックス全幅リスト表示",
"views.common.Appearance.mailListPreviewLineClamp": "本文プレビュー行数",
"views.common.Appearance.off": "オフ",
"views.index.SimpleIndex.refreshSuccess": "メールを更新しました",
"views.Admin.unknow": "受信者不明のメール",
"views.Admin.maintenance": "メンテナンス",
@@ -317,11 +351,11 @@ export const jaMessages = {
"views.admin.Account.multiClearInbox": "受信箱を一括削除",
"views.admin.Account.multiClearSentItems": "送信済みを一括削除",
"views.admin.Account.multiDelete": "一括削除",
"views.user.AddressManagement.name": "名前",
"views.user.AddressManagement.emailAddress": "メールアドレス",
"views.index.AutoReply.name": "名前",
"views.admin.Account.name": "名前",
"views.admin.Account.emailAddress": "メールアドレス",
"views.admin.UserOauth2Settings.name": "名前",
"views.admin.UserAddressManagement.name": "名前",
"views.admin.UserAddressManagement.emailAddress": "メールアドレス",
"views.admin.Maintenance.sqlName": "名前",
"views.index.AccountSettings.newPassword": "新しいパスワード",
"views.admin.Account.newPassword": "新しいパスワード",
@@ -330,7 +364,8 @@ export const jaMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
"views.index.SimpleIndex.noMails": "メールが見つかりません",
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者連絡してください。",
"views.index.SendMail.requestAccessTip": "送信権限と残高はメールアドレスごとに管理されます。現在のアドレス {address} には利用可能な残高がありません。このアドレスの権限申請するか、管理者連絡してください。",
"views.index.SendMail.requestSuccess": "現在のアドレスの送信権限を申請しました",
"components.SendBox.emptySent": "送信済みメールはありません",
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
"views.Admin.userOauth2Settings": "OAuth2設定",
@@ -369,6 +404,7 @@ export const jaMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
"views.admin.CreateAccount.generateName": "ランダム名を生成",
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
@@ -461,7 +497,7 @@ export const jaMessages = {
"views.admin.UserAddressManagement.send_count": "送信数",
"views.Index.sendmail": "メール送信",
"views.Admin.sendMail": "メール送信",
"views.index.SendMail.send_balance": "残り送信枠",
"views.index.SendMail.send_balance": "現在のアドレスの送信残高",
"views.admin.Statistics.sendMailCount": "送信メール数",
"views.admin.AccountSettings.send_mail_limit": "送信上限",
"views.user.UserLogin.sendVerificationCode": "認証コードを送信",
@@ -522,7 +558,7 @@ export const jaMessages = {
"views.index.LocalAddress.tip": "これらのアドレスはブラウザに保存されており、キャッシュを消すと失われる可能性があります。",
"views.admin.UserOauth2Settings.tip": "サードパーティログインではユーザーのメールアドレスで自動的にアカウント登録されます(同じメールは同一アカウントとして扱われます)。「パスワードを忘れた」からパスワード設定も可能です。",
"views.admin.AccountSettings.send_mail_limit_tip": "すべての送信チャネルに適用されます。-1 は無制限、0 は送信禁止です。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "これは RANDOM_SUBDOMAIN_DOMAINS と異なります。この設定では API 呼び出し側が独自サブドメインを指定でき、ランダムサブドメインは作成時に自動生成されるだけです。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS に列挙したベースドメインでは、ランダムまたは手動のサブドメインを利用できます。この設定は、他の許可済みベースドメインでも API からサブドメインを指定できるようにします。",
"views.index.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.admin.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.common.Appearance.top": "上",
@@ -551,13 +587,13 @@ export const jaMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
"views.Header.user": "ユーザー",
"views.Header.userCenter": "ユーザーセンター",
"views.Admin.user": "ユーザー",
"components.AddressSelect.userAddresses": "ユーザーのアドレス",
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
"components.AddressSelect.linkedMailboxes": "連携済みメールボックス",
"views.Admin.loginViaUserAdmin": "ユーザーアカウント(管理者ロール)",
"views.admin.Statistics.userCount": "ユーザー数",
"views.admin.UserManagement.user_email": "ユーザーメール",
"views.index.AddressBar.userLogin": "ユーザーログイン",
"views.index.AddressBar.userCenter": "ユーザーセンター",
"views.Admin.user_management": "ユーザー管理",
"views.Admin.user_settings": "ユーザー設定",
"views.User.user_settings": "ユーザー設定",
@@ -578,6 +614,10 @@ export const jaMessages = {
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.CreateAccount.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.admin.CreateAccount.normalSubdomain": "通常ドメイン",
"views.common.Login.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.common.Login.normalSubdomain": "通常ドメイン",
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
"views.Admin.workerconfig": "Worker設定",
@@ -613,5 +653,15 @@ export const jaMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
"components.AddressCredentialModal.username": "ユーザー名"
"components.AddressCredentialModal.username": "ユーザー名",
"views.User.send_mail": "メール送信",
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
"views.user.UserSendBox.sendbox": "送信済み",
"components.MailContentRenderer.markAsRead": "既読にする",
"components.MailContentRenderer.markAsUnread": "未読にする",
"components.MailContentRenderer.loadRemoteImages": "画像を読み込む",
"components.MailContentRenderer.remoteImagesBlocked": "プライバシー保護のため、{count} 件の外部リソースをブロックしました",
"views.user.AddressManagement.itemCount": "合計",
"views.index.SimpleIndex.readStatusUpdated": "既読状態を更新しました",
"views.common.Appearance.autoLoadRemoteImages": "メール本文の外部画像を自動的に読み込む"
}
+79 -29
View File
@@ -1,14 +1,44 @@
export const ptBRMessages = {
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
"views.index.SendMail.composeMail": "Escrever e-mail",
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
"views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
"views.index.SendMail.recipientAddress": "Endereço do destinatário",
"views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
"views.index.SendMail.senderAddress": "Endereço do remetente",
"views.index.SendMail.senderName": "Nome do remetente (opcional)",
"views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
"views.admin.SendMail.composeMail": "Escrever e-mail",
"views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
"views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
"views.admin.SendMail.recipientAddress": "Endereço do destinatário",
"views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
"views.admin.SendMail.senderAddress": "Endereço do remetente",
"views.admin.SendMail.senderName": "Nome do remetente (opcional)",
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Plano do Workers",
"views.admin.DatabaseManager.plan_placeholder": "Selecione seu plano do Cloudflare Workers",
"views.admin.DatabaseManager.planSaved": "Plano do Workers salvo",
"views.admin.DatabaseManager.single_database_limit": "Limite de capacidade do banco de dados",
"views.admin.DatabaseManager.storage_description": "Compare o tamanho atual do banco de dados com os limites do seu plano.",
"views.admin.DatabaseManager.storage_tip": "O uso é calculado com o tamanho atual do banco de dados e o limite do plano selecionado.",
"views.admin.DatabaseManager.storage_title": "Capacidade de armazenamento D1",
"views.admin.DatabaseManager.storage_usage": "Uso da capacidade",
"views.admin.DatabaseManager.unavailable": "Indisponível",
"views.index.SimpleIndex.mailCount": "{current} / {total} e-mails",
"views.admin.Statistics.activeAddressCount30days": "Quantidade de endereços ativos em 30 dias",
"views.admin.Statistics.activeAddressCount7days": "Quantidade de endereços ativos em 7 dias",
"views.Index.about": "Sobre",
"views.Admin.about": "Sobre",
"views.Header.accessHeader": "Senha de acesso",
"views.Admin.account": "Conta",
"views.Index.accountSettings": "Configurações da conta",
"views.Admin.account_settings": "Configurações da conta",
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
"views.Header.admin": "Painel administrativo",
"views.Admin.mailbox_management": "Gerenciamento de caixas de correio",
"views.Admin.mailbox_list": "Caixas de correio",
"views.Index.mailboxSettings": "Configurações da caixa de correio",
"views.Admin.mailbox_settings": "Configurações globais de caixas de correio",
"views.index.SimpleIndex.mailboxSettings": "Configurações da caixa de correio",
"views.index.Attachment.action": "Ação",
"views.admin.SenderAccess.action": "Ação",
"views.user.UserSettings.actions": "Ações",
@@ -22,7 +52,7 @@ export const ptBRMessages = {
"views.admin.Maintenance.customSqlTip": "Adicione instruções SQL DELETE personalizadas para a limpeza agendada. Apenas uma instrução DELETE é permitida por item.",
"views.admin.Maintenance.addCustomSql": "Adicionar SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
"components.AddressSelect.address": "Endereço",
"components.AddressSelect.selectMailbox": "Selecionar caixa de correio",
"views.user.AddressManagement.address": "Endereço",
"views.index.TelegramAddress.address": "Endereço",
"views.admin.SenderAccess.address": "Endereço",
@@ -58,10 +88,10 @@ export const ptBRMessages = {
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?",
"views.admin.Account.multiDeleteTip": "Tem certeza de que deseja excluir os endereços selecionados?",
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir esta caixa de correio?",
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir sua conta e todos os e-mails dela?",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço de e-mail e todas as mensagens dele? Isso não exclui sua conta de usuário.",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
@@ -74,6 +104,7 @@ export const ptBRMessages = {
"components.AiExtractInfo.authLink": "Link de autenticação",
"views.admin.Maintenance.autoCleanup": "Limpeza automática",
"components.MailBox.autoRefresh": "Atualização automática",
"components.MailBox.backToList": "Voltar para a lista",
"views.common.Appearance.autoRefreshInterval": "Intervalo de atualização automática (s)",
"views.Index.auto_reply": "Resposta automática",
"views.index.AutoReply.autoReply": "Resposta automática",
@@ -94,10 +125,10 @@ export const ptBRMessages = {
"views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
"views.user.AddressManagement.changeMailAddress": "Alterar endereço",
"views.user.AddressManagement.openMailbox": "Abrir caixa de correio",
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.AccountSettings.changePassword": "Alterar senha",
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
"views.admin.UserManagement.changeRole": "Alterar Função",
"components.SendBox.showCode": "Alternar visualização do código original",
"views.admin.Telegram.status": "Ver status",
@@ -133,8 +164,8 @@ export const ptBRMessages = {
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
"views.Footer.copyright": "Direitos autorais",
"views.Admin.account_create": "Criar conta",
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
"views.Admin.mailbox_create": "Criar endereço de e-mail",
"views.admin.CreateAccount.createEmailAddress": "Criar endereço de e-mail",
"views.common.Login.getNewEmail": "Criar novo e-mail",
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
@@ -169,8 +200,8 @@ export const ptBRMessages = {
"views.admin.UserManagement.delete": "Excluir",
"views.admin.AccountSettings.delete_rule": "Excluir",
"views.admin.Maintenance.deleteCustomSql": "Excluir",
"views.index.AccountSettings.deleteAccount": "Excluir conta",
"views.admin.Account.deleteAccount": "Excluir conta",
"views.index.AccountSettings.deleteMailbox": "Excluir caixa de correio",
"views.admin.Account.deleteMailbox": "Excluir caixa de correio",
"views.user.UserSettings.deletePasskey": "Excluir passkey",
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
"views.admin.UserManagement.deleteUser": "Excluir usuário",
@@ -241,7 +272,7 @@ export const ptBRMessages = {
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
"views.common.Appearance.globalTabplacement": "Posição global das abas",
"views.common.Login.help": "Ajuda",
"views.Header.home": "Início",
"views.Header.mailbox": "Caixa de correio",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
@@ -281,7 +312,7 @@ export const ptBRMessages = {
"views.user.UserSettings.logout": "Sair",
"views.user.BindAddress.logout": "Sair",
"views.Admin.logout": "Sair",
"views.index.AccountSettings.logout": "Sair",
"views.index.AccountSettings.logout": "Sair do endereço de e-mail atual",
"views.Admin.logoutSuccess": "Logout realizado com sucesso",
"views.admin.UserOauth2Settings.mailAllowList": "Lista branca de endereços de e-mail",
"views.admin.UserSettings.mailAllowList": "Lista branca de endereços de e-mail",
@@ -290,7 +321,7 @@ export const ptBRMessages = {
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
"views.admin.CreateAccount.addressCredential": "Credencial do endereço de e-mail",
"views.index.AddressBar.fetchAddressError": "A credencial do e-mail é inválida ou a conta não existe; também pode ser um problema de rede. Tente novamente mais tarde.",
"views.Index.mailbox": "Caixa de entrada",
"views.Index.inbox": "Caixa de entrada",
"views.User.user_mail_box_tab": "Caixa de entrada",
"views.user.AddressManagement.mail_count": "Quantidade de e-mails",
"views.admin.Account.mail_count": "Quantidade de e-mails",
@@ -299,7 +330,10 @@ export const ptBRMessages = {
"views.index.SimpleIndex.deleteSuccess": "E-mail excluído com sucesso",
"views.user.UserLogin.cannotForgotPassword": "A verificação por e-mail ou o registro está desativado; não é possível redefinir a senha. Entre em contato com o administrador.",
"views.Admin.mailWebhook": "Webhook de e-mail",
"views.common.Appearance.mailboxSplitSize": "Tamanho da divisão da caixa de correio",
"views.common.Appearance.mailboxSplitSize": "Largura da lista esquerda na visualização de duas colunas da caixa de correio",
"views.common.Appearance.mailListView": "Visualização de lista da caixa de correio em largura total",
"views.common.Appearance.mailListPreviewLineClamp": "Linhas da prévia do corpo",
"views.common.Appearance.off": "Desativado",
"views.index.SimpleIndex.refreshSuccess": "E-mails atualizados com sucesso",
"views.Admin.unknow": "E-mails com destinatário desconhecido",
"views.Admin.maintenance": "Manutenção",
@@ -317,11 +351,11 @@ export const ptBRMessages = {
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
"views.admin.Account.multiDelete": "Excluir em lote",
"views.user.AddressManagement.name": "Nome",
"views.user.AddressManagement.emailAddress": "Endereço de e-mail",
"views.index.AutoReply.name": "Nome",
"views.admin.Account.name": "Nome",
"views.admin.Account.emailAddress": "Endereço de e-mail",
"views.admin.UserOauth2Settings.name": "Nome",
"views.admin.UserAddressManagement.name": "Nome",
"views.admin.UserAddressManagement.emailAddress": "Endereço de e-mail",
"views.admin.Maintenance.sqlName": "Nome",
"views.index.AccountSettings.newPassword": "Nova senha",
"views.admin.Account.newPassword": "Nova senha",
@@ -330,7 +364,8 @@ export const ptBRMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de endereços sem limite de saldo",
"views.index.SimpleIndex.noMails": "Nenhum e-mail encontrado",
"views.admin.RoleAddressConfig.noRolesAvailable": "Nenhuma função disponível na configuração do sistema",
"views.index.SendMail.requestAccessTip": "Ainda não há saldo de envio. Se o administrador ativou um saldo padrão, ele será atribuído automaticamente; caso contrário, solicite acesso ou fale com o administrador.",
"views.index.SendMail.requestAccessTip": "A permissão e o saldo de envio são gerenciados separadamente para cada endereço. O endereço atual, {address}, não tem saldo disponível. Solicite permissão para este endereço ou fale com o administrador.",
"views.index.SendMail.requestSuccess": "Permissão de envio solicitada para o endereço atual",
"components.SendBox.emptySent": "Nenhum e-mail enviado",
"views.admin.RoleAddressConfig.notConfigured": "Não configurado (usar configurações globais)",
"views.Admin.userOauth2Settings": "Configurações de OAuth2",
@@ -369,6 +404,7 @@ export const ptBRMessages = {
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
"views.admin.CreateAccount.generateName": "Gerar nome aleatório",
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
"views.user.UserLogin.pleaseInputCode": "Informe o código",
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
@@ -461,7 +497,7 @@ export const ptBRMessages = {
"views.admin.UserAddressManagement.send_count": "Quantidade enviada",
"views.Index.sendmail": "Enviar e-mail",
"views.Admin.sendMail": "Enviar e-mail",
"views.index.SendMail.send_balance": "Saldo restante de envio",
"views.index.SendMail.send_balance": "Saldo de envio do endereço atual",
"views.admin.Statistics.sendMailCount": "Quantidade de e-mails enviados",
"views.admin.AccountSettings.send_mail_limit": "Limite de envio",
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificação",
@@ -522,7 +558,7 @@ export const ptBRMessages = {
"views.index.LocalAddress.tip": "Esses endereços ficam armazenados no navegador e podem ser perdidos se você limpar o cache.",
"views.admin.UserOauth2Settings.tip": "O login de terceiros usará automaticamente o e-mail do usuário para registrar uma conta (o mesmo e-mail será considerado a mesma conta). Você também pode definir a senha via “esqueci minha senha”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Aplica-se a todos os canais de envio. Use -1 para ilimitado e 0 para bloquear o envio.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Isso é diferente de RANDOM_SUBDOMAIN_DOMAINS: esta opção permite informar subdomínios personalizados; o subdomínio aleatório apenas gera um durante a criação.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS já permite subdomínios aleatórios ou manuais nos domínios base listados. Esta opção também permite que a API informe subdomínios em outros domínios base permitidos.",
"views.index.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.admin.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.common.Appearance.top": "topo",
@@ -551,13 +587,13 @@ export const ptBRMessages = {
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com",
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
"views.common.Appearance.useUTCDate": "Usar data UTC",
"views.Header.user": "Usuário",
"views.Header.userCenter": "Central do usuário",
"views.Admin.user": "Usuário",
"components.AddressSelect.userAddresses": "Endereços do usuário",
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
"components.AddressSelect.linkedMailboxes": "Caixas de correio vinculadas",
"views.Admin.loginViaUserAdmin": "Conta de usuário (função de administrador)",
"views.admin.Statistics.userCount": "Quantidade de usuários",
"views.admin.UserManagement.user_email": "E-mail do usuário",
"views.index.AddressBar.userLogin": "Login do usuário",
"views.index.AddressBar.userCenter": "Central do usuário",
"views.Admin.user_management": "Gerenciamento de usuários",
"views.Admin.user_settings": "Configurações do usuário",
"views.User.user_settings": "Configurações do usuário",
@@ -578,6 +614,10 @@ export const ptBRMessages = {
"views.admin.AiExtractSettings.disabledTip": "Quando desativado, a extração por IA processará todos os endereços",
"views.admin.AiExtractSettings.enableAllowListTip": "Quando ativado, a extração por IA só processará e-mails enviados aos endereços permitidos",
"views.admin.CreateAccount.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Domínio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.common.Login.normalSubdomain": "Domínio normal",
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
"views.admin.AiExtractSettings.allowListTip": "O curinga * corresponde a quaisquer caracteres; ex.: *{'@'}example.com corresponde a todos os endereços do domínio example.com",
"views.Admin.workerconfig": "Configuração do Worker",
@@ -613,5 +653,15 @@ export const ptBRMessages = {
"components.AddressCredentialModal.starttls": "STARTTLS",
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
"components.AddressCredentialModal.username": "Nome de usuário"
"components.AddressCredentialModal.username": "Nome de usuário",
"views.User.send_mail": "Enviar e-mail",
"views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
"views.user.UserSendBox.sendbox": "Enviados",
"components.MailContentRenderer.markAsRead": "Marcar como lido",
"components.MailContentRenderer.markAsUnread": "Marcar como não lido",
"components.MailContentRenderer.loadRemoteImages": "Carregar imagens",
"components.MailContentRenderer.remoteImagesBlocked": "{count} recursos externos foram bloqueados para proteger sua privacidade",
"views.user.AddressManagement.itemCount": "Total",
"views.index.SimpleIndex.readStatusUpdated": "Status de leitura atualizado",
"views.common.Appearance.autoLoadRemoteImages": "Carregar automaticamente imagens externas no corpo do e-mail"
}
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -1,11 +1,11 @@
import { LOCALE_REGISTRY, SUPPORTED_LOCALES } from './locale-registry'
import { APP_CONFIG } from '../config'
export { SUPPORTED_LOCALES } from './locale-registry'
export type { SupportedLocale } from './locale-registry'
import type { SupportedLocale } from './locale-registry'
export const DEFAULT_LOCALE: SupportedLocale = 'zh'
export const FALLBACK_LOCALE: SupportedLocale = 'zh'
export const PREFERRED_LOCALE_STORAGE_KEY = 'preferredLocale'
export const EMPTY_LOCALE_MESSAGES = Object.fromEntries(
@@ -29,6 +29,9 @@ export const resolveSupportedLocale = (locale: string | null | undefined): Suppo
return null
}
export const DEFAULT_LOCALE: SupportedLocale = resolveSupportedLocale(APP_CONFIG.DEFAULT_LANG)
|| FALLBACK_LOCALE
export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => {
if (!locale) return null
const normalizedLocale = locale.trim().toLowerCase()
@@ -135,4 +138,4 @@ const getLocaleAliasPath = (path: string, locale: SupportedLocale): string => {
}
return getPathWithLocale(basePath, locale)
}
}
+14 -3
View File
@@ -4,6 +4,7 @@ import User from '../views/User.vue'
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
import i18n from '../i18n'
import { useGlobalState } from '../store'
import { api } from '../api'
import {
DEFAULT_LOCALE,
getBrowserLocales,
@@ -12,7 +13,7 @@ import {
resolveSupportedLocale,
} from '../i18n/utils'
const { jwt, preferredLocale } = useGlobalState()
const { jwt, preferredLocale, openSettings } = useGlobalState()
const router = createRouter({
history: createWebHistory(),
@@ -27,6 +28,11 @@ const router = createRouter({
alias: '/:lang/user',
component: User
},
{
path: '/redeem',
alias: '/:lang/redeem',
component: () => import('../views/Redeem.vue')
},
{
path: '/user/oauth2/callback',
alias: '/:lang/user/oauth2/callback',
@@ -50,7 +56,7 @@ const router = createRouter({
]
});
router.beforeEach((to, from, next) => {
router.beforeEach(async (to, from, next) => {
const routeLocale = resolveSupportedLocale(to.path.split('/')[1])
const resolvedLocale = routeLocale || DEFAULT_LOCALE
i18n.global.locale.value = resolvedLocale
@@ -64,7 +70,12 @@ router.beforeEach((to, from, next) => {
if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
if (typeof jwtQuery === 'string') {
jwt.value = jwtQuery
try {
const config = openSettings.value.fetched ? openSettings.value : await api.fetch('/open_api/settings');
if (!config.addressPasswordLoginOnly) jwt.value = jwtQuery;
} catch {
// Do not import a login link until the server policy is known.
}
}
const query = { ...to.query }
delete query.jwt
+10
View File
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableMailReadStatus: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
@@ -41,7 +42,10 @@ export const useGlobalState = createGlobalState(
showGithubForUser: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
addressPasswordLoginOnly: false,
enableAgentEmailInfo: false,
enableRedeemCode: false,
redeemCodeUrl: '',
smtpImapProxyConfig: {
smtp: {
host: '',
@@ -88,6 +92,8 @@ export const useGlobalState = createGlobalState(
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const mailListView = useStorage('mailListView', false);
const mailListPreviewLineClamp = useStorage('mailListPreviewLineClamp', 2);
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const userJwt = useStorage('userJwt', '');
@@ -97,6 +103,7 @@ export const useGlobalState = createGlobalState(
const globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const useUTCDate = useStorage('useUTCDate', false);
const autoLoadRemoteImages = useStorage('autoLoadRemoteImages', true);
const autoRefresh = useStorage('autoRefresh', false);
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
const userOpenSettings = ref({
@@ -160,6 +167,8 @@ export const useGlobalState = createGlobalState(
adminMailTabAddress,
adminSendBoxTabAddress,
mailboxSplitSize,
mailListView,
mailListPreviewLineClamp,
useIframeShowMail,
preferShowTextMail,
userJwt,
@@ -171,6 +180,7 @@ export const useGlobalState = createGlobalState(
globalTabplacement,
useSideMargin,
useUTCDate,
autoLoadRemoteImages,
autoRefresh,
configAutoRefreshInterval,
telegramApp,
@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { blockRemoteContent } from '../remote-content-policy';
const T = 'https://tracker.example/p.png';
function leaks(html) {
const host = document.createElement('div');
host.innerHTML = html;
const f = [];
for (const el of host.querySelectorAll('*')) {
for (const a of el.attributes) {
if (a.name.startsWith('data-blocked-')) continue;
if (/tracker\.example/i.test(a.value)) f.push(`${el.tagName}[${a.name}]`);
}
if (el.tagName === 'STYLE' && /tracker\.example/i.test(el.textContent || '')) f.push('STYLE-text');
}
if (/tracker\.example/i.test(host.innerHTML) && !f.length) f.push('RAW');
return f;
}
const TAB = String.fromCharCode(9);
const NUL = String.fromCharCode(1);
const V = [
['noscript 突破', `<p>hi</p><noscript><b title="</noscript><img src=${T}>"></noscript>`],
['base href', `<base href="https://tracker.example/"><img src="/logo.png">`],
['iframe srcdoc', `<iframe srcdoc="&lt;img src=${T}&gt;"></iframe>`],
['script src', `<script src="${T}"></script>`],
['meta refresh', `<meta http-equiv="refresh" content="0;url=${T}">`],
['link preload', `<link rel="preload" as="image" href="${T}">`],
['link imagesrcset', `<link rel="preload" as="image" imagesrcset="${T} 1x">`],
['frame src', `<frameset><frame src="${T}"></frameset>`],
['style @import 註解', `<style>@import/**/"${T}";</style>`],
['style @import url', `<style>@import url(${T});</style>`],
['style background', `<style>.a{background:url("${T}")}</style><div class="a"></div>`],
['style image-set', `<style>.a{background:image-set('${T}' 1x)}</style>`],
['style 誘餌 url(', `<style>.a{content:"url(";background:url(${T})}</style>`],
['attr image-set', `<div style="background:image-set('${T}' 1x)">x</div>`],
['attr CSS 跳脫', `<div style="background:url(\\68 ttps://tracker.example/p.png)">x</div>`],
['attr CSS 函式名稱跳脫', `<div style="background:u\\72l(${T})">x</div>`],
['style CSS 函式名稱跳脫', `<style>.a{background:u\\72l(${T})}</style>`],
['style CSS at-rule 跳脫', `<style>@im\\70ort "${T}";</style>`],
['URL 反斜線', `<img src="https:\\\\tracker.example\\p.png">`],
['scheme 無斜線', `<img src="https:tracker.example/p.png">`],
['data:text/html iframe', `<iframe src="data:text/html,&lt;img src=${T}&gt;"></iframe>`],
['quoted src', `<img src="${T}">`],
['unquoted src', `<img src=${T}>`],
['srcset', `<img srcset="${T} 1x">`],
['src+srcset', `<img src="${T}" srcset="https://tracker.example/2x.png 2x">`],
['source srcset', `<picture><source srcset="${T}"><img src="cid:x"></picture>`],
['td background', `<table><tr><td background="${T}">x</td></tr></table>`],
['svg image href', `<svg><image href="${T}"/></svg>`],
['video poster', `<video poster="${T}"></video>`],
['tab 分割 scheme', `<img src="ht${TAB}tps://tracker.example/p.png">`],
['C0 控制字元前綴', `<img src="${NUL}${T}">`],
['entity scheme', `<img src="https&#58;//tracker.example/p.png">`],
['protocol-relative', `<img src="//tracker.example/p.png">`],
];
const KEEP = [
['cid', '<img src="cid:p@x">', 'cid:p@x'],
['data image', '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7">', 'data:image/gif'],
['blob', '<img src="blob:https://app.example/8f2c">', 'blob:'],
['相對路徑', '<img src="/assets/logo.png">', '/assets/logo.png'],
['排版 CSS', '<table><tr><td style="padding:8px;color:#333">hi</td></tr></table>', 'padding:8px'],
['style 區塊排版', '<style>.a{color:red;font-size:14px}</style><p class="a">x</p>', 'font-size:14px'],
['data: 於 CSS', '<div style="background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7)">x</div>', 'data:image/gif'],
['外部 a 連結', `<a href="${T}">open</a>`, T],
['外部 area 連結', `<map name="m"><area href="${T}" coords="0,0,1,1"></map>`, T],
];
describe('攻擊向量', () => {
it.each(V)('%s', (n, html) => {
const r = blockRemoteContent(html);
expect({ v: n, leaks: leaks(r.html) }).toEqual({ v: n, leaks: [] });
});
});
describe('必須保留', () => {
it.each(KEEP)('%s', (n, html, needle) => {
const r = blockRemoteContent(html);
expect({ v: n, kept: r.html.includes(needle), blocked: r.blocked })
.toEqual({ v: n, kept: true, blocked: 0 });
});
it('保留安全 CSS 跳脫與本地資源', () => {
const r = blockRemoteContent(
'<div style="font-family:\\41 rial;background:url(data:image/gif;base64,AAAA)">x</div>'
);
expect(r.blocked).toBe(0);
expect(r.html).toContain('font-family:\\41 rial');
expect(r.html).toContain('data:image/gif');
});
});
describe('阻斷計數', () => {
it('逐一計算 CSS 跳脫函式中的遠端資源', () => {
const r = blockRemoteContent(
'<div style="color:red;background:u\\72l(https://a.example/a.png),u\\72l(https://b.example/b.png)">x</div>'
);
expect(r.blocked).toBe(2);
expect(r.html).toContain('color:red');
expect(r.html).not.toContain('https://');
});
});
describe('危險導航協議', () => {
it.each([
['a javascript', '<a href="javascript:alert(1)">x</a>', 'a'],
['a data:text/html', '<a href="data:text/html,<script>alert(1)</script>">x</a>', 'a'],
['area javascript', '<map><area href="javascript:alert(1)"></map>', 'area'],
['area data:text/html', '<map><area href="data:text/html,<script>alert(1)</script>"></map>', 'area'],
])('%s', (_name, html, selector) => {
const host = document.createElement('div');
host.innerHTML = blockRemoteContent(html).html;
const node = host.querySelector(selector);
expect(node).not.toBeNull();
expect(node.hasAttribute('href')).toBe(false);
});
});
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import { sanitizeHtml } from '../sanitize-html';
describe('sanitizeHtml', () => {
it('preserves safe announcement markup', () => {
expect(sanitizeHtml('<strong>Notice</strong>')).toBe('<strong>Notice</strong>');
});
it('removes executable markup and unsafe attributes', () => {
const sanitized = sanitizeHtml(
'<script>alert(1)</script><img src="x" onerror="alert(1)">'
);
expect(sanitized).not.toContain('<script');
expect(sanitized).not.toContain('onerror');
expect(sanitized).toContain('<img src="x">');
});
it('returns an empty string for non-string values', () => {
expect(sanitizeHtml(null)).toBe('');
expect(sanitizeHtml({ value: '<strong>unsafe ref</strong>' })).toBe('');
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { useBreakpoint, useMemo } from 'vooks'
export function useIsMobile() {
const breakpointRef = useBreakpoint()
const breakpointRef = useBreakpoint({ xs: 0, s: 768 })
return useMemo(() => {
return breakpointRef.value === 'xs'
})
+1
View File
@@ -80,3 +80,4 @@ export function getDownloadEmlUrl(raw) {
new Blob([raw], { type: 'text/plain' }
))
}
+26
View File
@@ -0,0 +1,26 @@
export type CachedAddress = {
token: string;
address?: string;
type?: 'address_password_login' | null;
};
export const getCachedAddresses = (cache: (string | CachedAddress)[]): CachedAddress[] => cache
.map(entry => typeof entry === 'string' ? { token: entry } : entry)
.filter(entry => typeof entry?.token === 'string' && entry.token);
export const updateLocalAddressCache = (
cache: (string | CachedAddress)[], token: string,
{ address, type }: { address: string; type?: 'address_password_login' },
) => {
const entries = getCachedAddresses(cache);
const loginType = type ?? null;
const existing = entries.find(entry => entry.token === token
|| (entry.address === address && entry.type === loginType));
const updated = { token, address, type: loginType };
if (!existing) return [...entries, updated];
return entries.flatMap(entry => {
if (entry === existing) return [updated];
if (entry.token === token || (entry.address === address && entry.type === loginType)) return [];
return [entry];
});
};
+220
View File
@@ -0,0 +1,220 @@
import DOMPurify from 'dompurify';
// 1x1 transparent GIF, substituted for blocked remote images.
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7';
// Attributes whose value the browser resolves into a request.
const URL_ATTRIBUTES = new Set([
'src', 'srcset', 'imagesrcset', 'href', 'xlink:href',
'poster', 'background', 'data', 'action', 'formaction',
]);
const NAVIGATION_HREF_ELEMENTS = new Set(['A', 'AREA']);
// Elements that fetch on their own, redirect the frame, or re-base every
// relative URL in the document. None of them belong in a mail body, and
// <base> in particular would turn the relative paths we deliberately keep
// into requests to whatever host it names.
const FORBIDDEN = [
'base', 'meta', 'script', 'link', 'iframe', 'frame', 'frameset',
'object', 'embed', 'noscript', 'template', 'portal',
];
// DOMPurify's default scheme list has no blob:, but email-parser.js rewrites
// cid: attachments into blob: URLs -- without this every inline image would be
// stripped along with the trackers.
const ALLOWED_URI_REGEXP =
/^(?:(?:https?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
// CSS constructs that can load a resource. @import is listed because it also
// accepts a bare string -- `@import "https://..."` fetches without any url().
const CSS_FETCHES = /url\(|image-set|image\(|cross-fade|element\(|@import/i;
// A url(...) token or a bare string, either of which can name a resource.
const CSS_TOKEN = /url\(\s*(['"]?)([^'")]*)\1\s*\)|(['"])([^'"]*)\3/g;
const CSS_ESCAPE = /\\([0-9a-f]{1,6})[ \t\r\n\f]?|\\([^\r\n\f0-9a-f])/gi;
const CSS_ESCAPED_IDENTIFIER =
/@?(?:[-_a-z0-9]|\\(?:[0-9a-f]{1,6}[ \t\r\n\f]?|[^\r\n\f0-9a-f]))+/gi;
const CSS_FETCH_IDENTIFIERS = new Set([
'url', 'image-set', '-webkit-image-set', 'image', 'cross-fade',
'-webkit-cross-fade', 'element', '-moz-element', '@import',
]);
function decodeCssEscapes(value) {
return value.replace(CSS_ESCAPE, (_match, hex, escaped) => {
if (!hex) {
return escaped;
}
const codePoint = Number.parseInt(hex, 16);
if (codePoint === 0 || codePoint > 0x10FFFF ||
(codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
return '\uFFFD';
}
return String.fromCodePoint(codePoint);
});
}
function normalizeCssFetchIdentifiers(value) {
return value.replace(CSS_ESCAPED_IDENTIFIER, (identifier) => {
if (!identifier.includes('\\')) {
return identifier;
}
const decoded = decodeCssEscapes(identifier);
return CSS_FETCH_IDENTIFIERS.has(decoded.toLowerCase()) ? decoded : identifier;
});
}
/**
* Whether a URL can be *proven* to stay off the network.
*
* This is deliberately an allowlist. Asking "does this look remote?" means
* enumerating every way a scheme can be disguised -- backslashes, tabs and
* control characters the URL parser strips, CSS escapes, schemes with no
* slashes -- and losing to the first one not thought of. Asking "can I prove
* this is local?" fails closed instead: anything unrecognised is blocked.
*
* Relative paths qualify only because <base> is removed above, so they can
* resolve to nothing but our own origin.
*/
function provablyLocal(value) {
const url = String(value ?? '').trim();
if (url === '') {
return true;
}
if (/^(?:cid:|blob:|data:image\/)/i.test(url)) {
return true;
}
// Relative: starts with a path/query/fragment marker and is not the
// protocol-relative "//host" form (or its backslash equivalent).
if (/^[/.?#]/.test(url)) {
return !/^[/\\]{2}/.test(url);
}
// No scheme separator and no backslash at all -- a bare relative filename.
return !/[:\\]/.test(url);
}
/** srcset holds several candidates; every one of them has to be local. */
function srcsetIsLocal(value) {
return String(value ?? '')
.split(',')
.map((candidate) => candidate.trim().split(/\s+/)[0])
.filter(Boolean)
.every(provablyLocal);
}
/**
* Replaces every resource reference in a chunk of CSS that cannot be proven
* local. Tokens are substituted in place rather than whole declarations
* dropped, so the surrounding rule structure survives.
*/
function blockCssUrls(cssText, onBlocked) {
const normalizedCssText = normalizeCssFetchIdentifiers(cssText);
if (!CSS_FETCHES.test(normalizedCssText)) {
return cssText;
}
return normalizedCssText.replace(CSS_TOKEN, (match, urlQuote, urlValue, strQuote, strValue) => {
const isUrlToken = urlValue !== undefined;
const token = isUrlToken ? urlValue : strValue;
if (provablyLocal(token)) {
return match;
}
onBlocked();
return isUrlToken
? `url(${urlQuote}${TRANSPARENT_PIXEL}${urlQuote})`
: `${strQuote}${TRANSPARENT_PIXEL}${strQuote}`;
});
}
let purifier = null;
let blockedCount = 0;
/**
* An isolated DOMPurify instance. The hooks below must not reach the shared
* singleton, which mail-actions.js uses when building replies -- quoting a
* mail should keep its images.
*/
function getPurifier() {
if (purifier) {
return purifier;
}
purifier = DOMPurify(window);
purifier.addHook('uponSanitizeAttribute', (node, data) => {
if (!URL_ATTRIBUTES.has(data.attrName)) {
return;
}
if (data.attrName === 'href' && NAVIGATION_HREF_ELEMENTS.has(node.tagName)) {
return;
}
const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset';
if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) {
return;
}
blockedCount += 1;
data.keepAttr = false;
// The original URL is dropped rather than parked in a data-* attribute:
// "the cleaned body contains no remote URL at all" is an invariant that
// can be asserted directly, and restoring images re-renders from the
// untouched source anyway. <img> keeps a placeholder so layout holds.
if (data.attrName === 'src' && node.tagName === 'IMG') {
node.setAttribute('src', TRANSPARENT_PIXEL);
}
});
purifier.addHook('afterSanitizeElements', (node) => {
if (node.tagName === 'STYLE') {
const cleaned = blockCssUrls(node.textContent || '', () => { blockedCount += 1; });
if (cleaned !== node.textContent) {
node.textContent = cleaned;
}
}
});
purifier.addHook('afterSanitizeAttributes', (node) => {
const style = node.getAttribute && node.getAttribute('style');
if (!style) {
return;
}
const cleaned = blockCssUrls(style, () => { blockedCount += 1; });
if (cleaned !== style) {
node.setAttribute('style', cleaned);
}
});
return purifier;
}
/**
* Strips everything in an email body that would make the browser fetch from a
* third party, so opening the mail cannot be used to confirm it was read.
*
* Sanitising is delegated to DOMPurify rather than hand-rolled: the hard part
* is not enumerating attributes but surviving the parser, and mutation-XSS is
* DOMPurify's specialty. A hand-written pass over a DOMParser tree missed, for
* one example, that <noscript> is parsed as markup where scripting is off and
* as raw text where it is on -- so a `</noscript>` smuggled into an attribute
* value reopens the document at render time and revives an <img> that the
* cleaner never saw.
*
* @param {string} html
* @returns {{ html: string, blocked: number }} blocked counts the references removed
*/
export function blockRemoteContent(html) {
if (!html || typeof html !== 'string') {
return { html: html || '', blocked: 0 };
}
blockedCount = 0;
const sanitised = getPurifier().sanitize(html, {
FORBID_TAGS: FORBIDDEN,
// Mail layout leans on <style> blocks, so they are kept and their
// url() references filtered instead of dropping the tag wholesale.
// FORCE_BODY is what makes a leading <style> survive: without it the
// parser hoists it into <head> and DOMPurify discards it.
ADD_TAGS: ['style'],
FORCE_BODY: true,
ALLOWED_URI_REGEXP,
ALLOW_DATA_ATTR: true,
});
return { html: sanitised, blocked: blockedCount };
}
+5
View File
@@ -0,0 +1,5 @@
import DOMPurify from 'dompurify';
export const sanitizeHtml = (html) => {
return DOMPurify.sanitize(typeof html === 'string' ? html : '');
};
+15 -11
View File
@@ -30,6 +30,7 @@ import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
import AiExtractSettings from './admin/AiExtractSettings.vue';
import RedeemCodes from './admin/RedeemCodes.vue';
const {
adminAuth, showAdminAuth, adminTab, loading,
@@ -106,7 +107,7 @@ onMounted(async () => {
</script>
<template>
<div v-if="userSettings.fetched">
<div v-if="openSettings.fetched && userSettings.fetched">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
@@ -120,11 +121,11 @@ onMounted(async () => {
</n-modal>
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="quick-setup-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
@@ -135,15 +136,15 @@ onMounted(async () => {
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="account" :tab="t('account')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="account" :tab="t('account')">
<n-tab-pane name="account" :tab="t('mailbox_management')">
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="account" :tab="t('mailbox_list')">
<Account />
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<n-tab-pane name="account_create" :tab="t('mailbox_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
@@ -161,7 +162,7 @@ onMounted(async () => {
</n-tabs>
</n-tab-pane>
<n-tab-pane name="user" :tab="t('user')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="user-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
@@ -177,7 +178,7 @@ onMounted(async () => {
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="mails-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
@@ -201,8 +202,11 @@ onMounted(async () => {
<n-tab-pane name="statistics" :tab="t('statistics')">
<Statistics />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableRedeemCode" name="redeemCodes" :tab="t('redeemCodes')">
<RedeemCodes />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
+40 -9
View File
@@ -9,7 +9,7 @@ import {
AdminPanelSettingsFilled, MonitorHeartFilled,
KeyboardArrowDownOutlined, OpenInNewOutlined
} from '@vicons/material'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { Envelope, GithubAlt, Language, User } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
@@ -123,8 +123,8 @@ const menuOptions = computed(() => [
}
},
{
default: () => t('home'),
icon: () => h(NIcon, { component: Home })
default: () => t('mailbox'),
icon: () => h(NIcon, { component: Envelope })
}),
key: "home"
},
@@ -142,7 +142,7 @@ const menuOptions = computed(() => [
}
},
{
default: () => t('user'),
default: () => t('userCenter'),
icon: () => h(NIcon, { component: User }),
}
),
@@ -165,7 +165,7 @@ const menuOptions = computed(() => [
}
},
{
default: () => "Admin",
default: () => t('admin'),
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
@@ -331,6 +331,32 @@ onMounted(async () => {
</template>
<style scoped>
:deep(.n-page-header) {
align-items: center;
flex-wrap: nowrap;
}
:deep(.n-page-header__main) {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
}
:deep(.n-page-header__title) {
min-width: 0;
overflow: hidden;
}
:deep(.n-page-header__title h3) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.n-page-header__extra) {
flex: 0 0 auto;
}
.n-layout-header {
display: flex;
align-items: center;
@@ -435,15 +461,20 @@ onMounted(async () => {
}
@media (max-width: 640px) {
:deep(.n-page-header) {
padding: 10px 12px;
}
:deep(.n-page-header__title) {
min-width: 0;
}
:deep(.n-page-header__title h3) {
max-width: calc(100vw - 136px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100vw - 104px);
margin: 0;
font-size: clamp(16px, 5vw, 20px);
line-height: 1.2;
}
}
</style>
+12 -3
View File
@@ -45,6 +45,14 @@ const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
const updateMailReadStatus = async (id, isUnread) => {
await api.fetch(`/api/mails/${id}/read`, {
method: 'PATCH',
body: JSON.stringify({ isUnread }),
showLoading: false
})
}
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
@@ -116,7 +124,7 @@ onMounted(() => {
{{ t('enterSimpleMode') }}
</n-button>
</template>
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<n-tab-pane name="mailbox" :tab="t('inbox')">
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group>
<n-input v-model:value="mailIdQuery" />
@@ -127,7 +135,8 @@ onMounted(() => {
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
:enableMailReadStatus="openSettings.enableMailReadStatus" :updateMailReadStatus="updateMailReadStatus" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
@@ -136,7 +145,7 @@ onMounted(() => {
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<n-tab-pane name="accountSettings" :tab="t('mailboxSettings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
+627
View File
@@ -0,0 +1,627 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
AlternateEmailOutlined,
ArrowBackOutlined,
CheckCircleOutlined,
RedeemOutlined,
SendOutlined,
} from '@vicons/material'
import { api } from '../api'
import AddressCredentialContent from '../components/AddressCredentialContent.vue'
import { useGlobalState } from '../store'
import { useScopedI18n } from '../i18n/app'
import { getRouterPathWithLang } from '../utils'
const router = useRouter()
const message = useMessage()
const notification = useNotification()
const { locale, t } = useScopedI18n('views.Redeem')
const {
jwt,
settings,
openSettings,
userSettings,
addressPassword,
showAddressCredential,
} = useGlobalState()
const code = ref('')
const codeInfo = ref(null)
const redeemStarted = ref(false)
const completed = ref(null)
const targetUserEmail = ref('')
const targetAddress = ref(settings.value.address || '')
const emailName = ref('')
const emailDomain = ref('')
const subdomainMode = ref('normal')
const customSubdomain = ref('')
let latestQueryRequest = 0
const activeRedeemType = computed(() => completed.value?.type || codeInfo.value?.redeem_type || '')
const codeStatusType = computed(() => {
if (codeInfo.value?.status === 'expired') return 'error'
return codeInfo.value?.status === 'unused' ? 'success' : 'default'
})
const redeemTypes = computed(() => [
{
type: 'role',
icon: RedeemOutlined,
title: t('roleTitle'),
},
{
type: 'send_balance',
icon: SendOutlined,
title: t('balanceTitle'),
},
{
type: 'address_prefix_once',
icon: AlternateEmailOutlined,
title: t('prefixTitle'),
},
])
const redeemCodeUrl = computed(() => {
try {
const url = new URL(openSettings.value.redeemCodeUrl)
return ['http:', 'https:'].includes(url.protocol) ? url.href : ''
} catch {
return ''
}
})
const domainsOptions = computed(() => {
const allowed = openSettings.value.defaultDomains?.length
? openSettings.value.defaultDomains
: openSettings.value.domains.map((item) => item.value)
return openSettings.value.domains.filter((item) => allowed.includes(item.value))
})
const canUseRandomSubdomain = computed(() => (
openSettings.value.randomSubdomainDomains?.includes(emailDomain.value)
))
const addressNameMaxLength = computed(() => Math.max(
(openSettings.value.maxAddressLen || 30) - (codeInfo.value?.value?.length || 0),
1,
))
const addressNameMinLength = computed(() => Math.min(
addressNameMaxLength.value,
Math.max(
(openSettings.value.minAddressLen || 1) - (codeInfo.value?.value?.length || 0),
1,
),
))
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) subdomainMode.value = 'normal'
})
watch(addressNameMaxLength, (maxLength) => {
emailName.value = emailName.value.slice(0, maxLength)
})
watch(domainsOptions, (options) => {
if (!options.some((item) => item.value === emailDomain.value)) {
emailDomain.value = options[0]?.value || ''
}
}, { immediate: true })
watch(code, () => {
latestQueryRequest += 1
codeInfo.value = null
redeemStarted.value = false
})
watch(() => userSettings.value.user_email, (email) => {
if (!targetUserEmail.value) targetUserEmail.value = email || ''
}, { immediate: true })
watch(() => settings.value.address, (address) => {
if (!targetAddress.value) targetAddress.value = address || ''
}, { immediate: true })
const loadContext = async () => {
if (!openSettings.value.fetched) {
await api.getOpenSettings(message, notification)
}
if (!openSettings.value.enableRedeemCode) {
await router.replace(getRouterPathWithLang('/', locale.value))
return
}
if (jwt.value && !settings.value.address) {
try {
await api.getSettings()
} catch (error) {
console.error(error)
}
}
}
const queryCode = async () => {
const queriedCode = code.value.trim()
if (!queriedCode) return
const requestId = ++latestQueryRequest
try {
const result = await api.fetch('/redeem_api/query', {
method: 'POST',
body: JSON.stringify({ code: queriedCode }),
})
if (requestId !== latestQueryRequest || code.value.trim() !== queriedCode) return
codeInfo.value = result
redeemStarted.value = false
completed.value = null
await loadContext()
} catch (error) {
if (requestId !== latestQueryRequest) return
codeInfo.value = null
message.error(error.message || 'error')
}
}
const resetRedemption = () => {
code.value = ''
codeInfo.value = null
redeemStarted.value = false
completed.value = null
}
const redeem = async () => {
if (!codeInfo.value) return
try {
let result
if (codeInfo.value.redeem_type === 'role') {
if (!targetUserEmail.value.trim()) return
result = await api.fetch('/redeem_api/redeem', {
method: 'POST',
body: JSON.stringify({
code: code.value,
user_email: targetUserEmail.value.trim(),
}),
})
} else if (codeInfo.value.redeem_type === 'send_balance') {
if (!targetAddress.value.trim()) return
result = await api.fetch('/redeem_api/redeem', {
method: 'POST',
body: JSON.stringify({
code: code.value,
address: targetAddress.value.trim(),
}),
})
}
completed.value = result
codeInfo.value = null
if (result?.type === 'role'
&& result.user_email.toLowerCase() === userSettings.value.user_email?.trim().toLowerCase()
) {
await api.getUserSettings(message)
}
} catch (error) {
message.error(error.message || 'error')
}
}
const generateName = () => {
const length = Math.min(
addressNameMaxLength.value,
Math.max(addressNameMinLength.value, 8),
)
const charset = 'abcdefghijklmnopqrstuvwxyz0123456789'
const bytes = crypto.getRandomValues(new Uint8Array(length))
emailName.value = Array.from(bytes, (value) => charset[value % charset.length]).join('')
}
const createAddress = async () => {
if (!codeInfo.value || codeInfo.value.redeem_type !== 'address_prefix_once') return
try {
const domain = subdomainMode.value === 'custom'
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value
const result = await api.fetch('/redeem_api/redeem', {
method: 'POST',
body: JSON.stringify({
code: code.value,
name: openSettings.value.disableCustomAddressName ? '' : emailName.value,
domain,
enableRandomSubdomain: subdomainMode.value === 'random',
}),
})
completed.value = result
codeInfo.value = null
} catch (error) {
message.error(error.message || 'error')
}
}
const showRedeemResult = async () => {
try {
completed.value = await api.fetch('/redeem_api/result', {
method: 'POST',
body: JSON.stringify({ code: code.value }),
})
codeInfo.value = null
} catch (error) {
message.error(error.message || 'error')
}
}
const useRedeemedAddress = async () => {
if (completed.value?.type !== 'address_prefix_once') return
jwt.value = completed.value.jwt
addressPassword.value = completed.value.password || ''
await api.getSettings()
showAddressCredential.value = true
await router.push(getRouterPathWithLang('/', locale.value))
}
const goBack = async () => {
await router.push(getRouterPathWithLang('/', locale.value))
}
onMounted(loadContext)
</script>
<template>
<main v-if="openSettings.enableRedeemCode" class="redeem-page">
<section class="redeem-shell">
<n-card class="redeem-card" :bordered="false" embedded>
<n-button text class="back-link" @click="goBack">
<template #icon>
<n-icon :component="ArrowBackOutlined" />
</template>
{{ t('back') }}
</n-button>
<div class="heading-copy">
<div class="heading-icon">
<n-icon :component="RedeemOutlined" />
</div>
<div>
<h1>{{ t('title') }}</h1>
</div>
</div>
<div class="type-section">
<div class="section-label">{{ t('categoryTitle') }}</div>
<div class="type-grid">
<div v-for="item in redeemTypes" :key="item.type" class="type-card"
:class="{ active: activeRedeemType === item.type }">
<n-icon :component="item.icon" />
<span>{{ item.title }}</span>
</div>
</div>
</div>
<n-divider />
<div v-if="completed" class="success-state">
<n-icon :component="CheckCircleOutlined" />
<h2>{{ t('success') }}</h2>
<p v-if="completed.type === 'role'">
{{ t('roleSuccess', { role: completed.role, email: completed.user_email }) }}
</p>
<p v-else-if="completed.type === 'send_balance'">
{{ t('balanceSuccess', { amount: completed.amount, address: completed.address }) }}
</p>
<template v-else>
<p>{{ t('addressSuccess') }}</p>
<AddressCredentialContent class="credential-result" :address="completed.address"
:jwt="completed.jwt" :address-password="completed.password" />
<n-button type="primary" block @click="useRedeemedAddress">
{{ t('openAddress') }}
</n-button>
</template>
<n-button type="primary" secondary @click="resetRedemption">
{{ t('redeemAnother') }}
</n-button>
</div>
<div v-else class="redeem-form">
<n-form-item :label="t('codeLabel')" :show-feedback="false">
<n-input data-testid="redeem-code-input" v-model:value="code" size="large" :placeholder="t('codePlaceholder')"
@keyup.enter="queryCode" />
</n-form-item>
<n-button v-if="!codeInfo" type="primary" size="large" block :disabled="!code.trim()"
@click="queryCode">
{{ t('checkCode') }}
</n-button>
<n-button v-if="redeemCodeUrl" data-testid="redeem-code-link" class="get-code-link"
text tag="a" target="_blank" rel="noopener noreferrer" :href="redeemCodeUrl">
{{ t('getCode') }}
</n-button>
<div v-if="codeInfo" class="redemption-detail">
<n-divider />
<template v-if="codeInfo.redeem_type === 'role'">
<div class="detail-heading">
<n-icon :component="RedeemOutlined" />
<div>
<h2>{{ t('roleTitle') }}</h2>
<p>{{ t('roleDescription', { role: codeInfo.value }) }}</p>
</div>
<n-tag :type="codeStatusType" :bordered="false">
{{ t(codeInfo.status) }}
</n-tag>
</div>
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
@click="redeemStarted = true">
{{ t('redeemNow') }}
</n-button>
<template v-else-if="codeInfo.status === 'unused'">
<n-form-item :label="t('userEmail')">
<n-input data-testid="redeem-user-email" v-model:value="targetUserEmail"
:placeholder="t('userEmailPlaceholder')" />
</n-form-item>
<n-button type="primary" block :disabled="!targetUserEmail.trim()" @click="redeem">
{{ t('confirmRole') }}
</n-button>
</template>
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
type="primary" block @click="showRedeemResult">
{{ t('viewResult') }}
</n-button>
</template>
<template v-else-if="codeInfo.redeem_type === 'send_balance'">
<div class="detail-heading">
<n-icon :component="SendOutlined" />
<div>
<h2>{{ t('balanceTitle') }}</h2>
<p>{{ t('balanceDescription', { amount: codeInfo.value }) }}</p>
</div>
<n-tag :type="codeStatusType" :bordered="false">
{{ t(codeInfo.status) }}
</n-tag>
</div>
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
@click="redeemStarted = true">
{{ t('redeemNow') }}
</n-button>
<template v-else-if="codeInfo.status === 'unused'">
<n-form-item :label="t('targetAddress')">
<n-input data-testid="redeem-target-address" v-model:value="targetAddress"
:placeholder="t('targetAddress')" />
</n-form-item>
<n-button type="primary" block :disabled="!targetAddress.trim()" @click="redeem">
{{ t('confirmBalance') }}
</n-button>
</template>
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
type="primary" block @click="showRedeemResult">
{{ t('viewResult') }}
</n-button>
</template>
<template v-else>
<div class="detail-heading">
<n-icon :component="AlternateEmailOutlined" />
<div>
<h2>{{ t('prefixTitle') }}</h2>
<p>{{ codeInfo.value
? t('prefixDescription', { prefix: codeInfo.value })
: t('noPrefixDescription') }}</p>
</div>
<n-tag :type="codeStatusType" :bordered="false">
{{ t(codeInfo.status) }}
</n-tag>
</div>
<n-button v-if="codeInfo.status === 'redeemed'" data-testid="redeem-result" type="primary" block
@click="showRedeemResult">
{{ t('viewResult') }}
</n-button>
<n-form v-else-if="codeInfo.status === 'unused'">
<n-form-item :label="t('address')">
<n-input-group>
<n-input-group-label v-if="codeInfo.value">{{ codeInfo.value }}</n-input-group-label>
<n-input v-if="!openSettings.disableCustomAddressName" data-testid="redeem-address-name"
v-model:value="emailName"
:minlength="addressNameMinLength" :maxlength="addressNameMaxLength" />
<n-input v-else :value="t('autoName')" disabled />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :options="domainsOptions"
:consistent-menu-width="false" />
</n-input-group>
</n-form-item>
<n-button v-if="!openSettings.disableCustomAddressName" size="small" @click="generateName">
{{ t('randomName') }}
</n-button>
<n-form-item v-if="canUseRandomSubdomain" class="subdomain-options">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalDomain') }}</n-radio>
<n-radio value="random">{{ t('randomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('customSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<n-input-group v-if="subdomainMode === 'custom'">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</n-form-item>
<n-button type="primary" block
:disabled="!emailDomain || (subdomainMode === 'custom' && !customSubdomain.trim())"
@click="createAddress">
{{ t('createAddress') }}
</n-button>
</n-form>
</template>
</div>
</div>
</n-card>
</section>
</main>
</template>
<style scoped>
.redeem-page {
min-height: 70vh;
padding: 24px 18px 56px;
}
.redeem-shell {
width: min(720px, 100%);
margin: 0 auto;
text-align: left;
}
.back-link {
margin-bottom: 12px;
}
.redeem-card {
border-radius: 10px;
}
.heading-copy {
display: flex;
align-items: center;
gap: 12px;
}
.heading-icon {
display: grid;
flex: 0 0 auto;
width: 42px;
height: 42px;
place-items: center;
color: #18a058;
font-size: 24px;
background: rgba(24, 160, 88, 0.12);
border-radius: 8px;
}
.heading-copy h1 {
margin: 0;
font-size: 24px;
line-height: 1.3;
}
.type-section {
margin-top: 22px;
}
.section-label {
margin-bottom: 10px;
font-weight: 600;
}
.type-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.type-card {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 12px;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 8px;
}
.type-card .n-icon {
flex: 0 0 auto;
color: #18a058;
font-size: 20px;
}
.type-card span {
min-width: 0;
}
.type-card.active {
color: #18a058;
border-color: rgba(24, 160, 88, 0.55);
background: rgba(24, 160, 88, 0.08);
}
.redeem-card > :deep(.n-card__content) > .n-divider {
margin: 22px 0;
}
.redeem-form > :deep(.n-form-item) {
margin-bottom: 14px;
}
.redeem-form > .n-button {
margin-top: 2px;
}
.redeem-form > .get-code-link {
width: max-content;
margin: 12px auto 0;
}
.redemption-detail :deep(.n-button),
.success-state > .n-button {
margin-top: 14px;
}
.detail-heading {
display: flex;
gap: 14px;
align-items: flex-start;
margin-bottom: 18px;
}
.detail-heading > .n-icon {
flex: 0 0 auto;
color: #18a058;
font-size: 28px;
}
.detail-heading > .n-tag {
flex: 0 0 auto;
margin-left: auto;
}
.detail-heading p,
.success-state p {
margin: 0;
opacity: 0.68;
}
.detail-heading h2,
.success-state h2 {
margin: 0 0 4px;
font-size: 20px;
}
.subdomain-options :deep(.n-form-item-blank) {
display: grid;
gap: 12px;
}
.success-state {
padding: 24px 8px;
text-align: center;
}
.success-state > .n-icon {
color: #18a058;
font-size: 50px;
}
.credential-result {
margin-top: 18px;
}
@media (max-width: 640px) {
.redeem-page {
padding: 16px 12px 48px;
}
.heading-copy h1 {
font-size: 21px;
}
.type-card {
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 10px 5px;
text-align: center;
}
}
</style>
+9 -1
View File
@@ -8,12 +8,14 @@ import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
import UserMailBox from './user/UserMailBox.vue';
import UserSendBox from './user/UserSendBox.vue';
const {
userTab, globalTabplacement, userSettings
userTab, globalTabplacement, userSettings, openSettings
} = useGlobalState()
const { t } = useScopedI18n('views.User')
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
</script>
@@ -27,6 +29,12 @@ const { t } = useScopedI18n('views.User')
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
<UserMailBox />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="user_sendbox" :tab="userMailT('sendbox')">
<UserSendBox mode="sendbox" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
<UserSendBox mode="send_mail" @sent="userTab = 'user_sendbox'" />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
+3 -3
View File
@@ -265,7 +265,7 @@ const columns = computed(() => [
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
},
{
title: t('name'),
title: t('emailAddress'),
key: "name",
sorter: true,
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
@@ -479,11 +479,11 @@ onMounted(async () => {
<div style="margin-top: 10px;">
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
:jwt="curEmailCredential" />
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('deleteAccount') }}
{{ t('deleteMailbox') }}
</n-button>
</template>
</n-modal>
+77 -21
View File
@@ -14,7 +14,8 @@ const message = useMessage()
const { t } = useScopedI18n('views.admin.CreateAccount')
const enablePrefix = ref(true)
const enableRandomSubdomain = ref(false)
const subdomainMode = ref("normal")
const customSubdomain = ref("")
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
@@ -22,6 +23,40 @@ const result = ref("")
const addressPassword = ref("")
const createdAddress = ref("")
const addressRegex = computed(() => {
try {
if (openSettings.value.addressRegex) {
return new RegExp(openSettings.value.addressRegex, 'g');
}
} catch (error) {
console.error(error);
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
}
return /[^a-z0-9]/g;
});
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.internet.email()
.split('@')[0]
.replace(/\s+/g, '.')
.replace(/\.{2,}/g, '.')
.replace(addressRegex.value, '')
.toLowerCase();
// support maxAddressLen
if (emailName.value.length > openSettings.value.maxAddressLen) {
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
}
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const canUseRandomSubdomain = computed(() => {
if (!emailDomain.value) {
return false
@@ -31,7 +66,7 @@ const canUseRandomSubdomain = computed(() => {
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false
subdomainMode.value = "normal"
}
})
@@ -41,13 +76,16 @@ const newEmail = async () => {
return
}
try {
const domain = subdomainMode.value === "custom"
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value
const res = await api.fetch(`/admin/new_address`, {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
enableRandomSubdomain: enableRandomSubdomain.value,
enableRandomSubdomain: subdomainMode.value === "random",
name: emailName.value,
domain: emailDomain.value,
domain,
})
})
result.value = res["jwt"];
@@ -77,26 +115,44 @@ onMounted(async () => {
<n-switch v-model:value="enablePrefix" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('address')">
<n-input-group>
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
<n-spin :show="generateNameLoading" style="width: 100%;">
<div>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
</div>
</n-spin>
</n-form-item-row>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<div style="width: 100%;">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</div>
</n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading">
{{ t('creatNewEmail') }}
<n-button @click="newEmail" type="primary" block :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
{{ t('createEmailAddress') }}
</n-button>
</n-card>
</div>
+211 -4
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useScopedI18n } from '@/i18n/app'
import { CleaningServicesFilled } from '@vicons/material'
@@ -7,24 +7,95 @@ import { api } from '../../api'
import { init } from 'vooks/lib/on-fonts-ready';
const message = useMessage()
const D1_STORAGE_PLAN_CONFIG_KEY = 'd1_storage_plan'
const dbVersionData = ref({
need_initialization: false,
need_migration: false,
current_db_version: '',
code_db_version: ''
code_db_version: '',
database_size: null
})
const selectedPlan = ref(null)
const savedPlan = ref(null)
const savingPlan = ref(false)
const planOptions = computed(() => [
{
label: t('free_plan'),
value: 'free',
databaseLimit: 500 * 1024 ** 2
},
{
label: t('paid_plan'),
value: 'paid',
databaseLimit: 10 * 1024 ** 3
}
])
const selectedPlanDetails = computed(() => (
planOptions.value.find((plan) => plan.value === selectedPlan.value)
))
const storagePercentage = computed(() => {
if (!selectedPlanDetails.value || dbVersionData.value.database_size === null) return 0
return dbVersionData.value.database_size / selectedPlanDetails.value.databaseLimit * 100
})
const progressPercentage = computed(() => Math.min(storagePercentage.value, 100))
const progressStatus = computed(() => {
if (storagePercentage.value >= 90) return 'error'
if (storagePercentage.value >= 75) return 'warning'
return 'success'
})
const { t } = useScopedI18n('views.admin.DatabaseManager')
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined) return t('unavailable')
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** unitIndex
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIndex]}`
}
const fetchData = async () => {
try {
const res = await api.fetch('/admin/db_version');
if (res) Object.assign(dbVersionData.value, res);
const [versionRes, configRes] = await Promise.all([
api.fetch('/admin/db_version'),
api.fetch(`/admin/config/${D1_STORAGE_PLAN_CONFIG_KEY}`)
]);
if (versionRes) Object.assign(dbVersionData.value, versionRes);
const configuredPlan = configRes?.value
if (planOptions.value.some((plan) => plan.value === configuredPlan)) {
selectedPlan.value = configuredPlan
savedPlan.value = configuredPlan
}
} catch (error) {
message.error(error.message || "error");
}
}
const savePlan = async (plan) => {
savingPlan.value = true
try {
await api.fetch('/admin/config', {
method: 'POST',
body: { key: D1_STORAGE_PLAN_CONFIG_KEY, value: plan }
})
savedPlan.value = plan
message.success(t('planSaved'))
} catch (error) {
selectedPlan.value = savedPlan.value
message.error(error.message || "error")
} finally {
savingPlan.value = false
}
}
const initialization = async () => {
try {
await api.fetch('/admin/db_initialize', {
@@ -77,6 +148,58 @@ onMounted(async () => {
</span>
</n-alert>
<div class="storage-panel">
<div class="storage-heading">
<div>
<h3>{{ t('storage_title') }}</h3>
<p>{{ t('storage_description') }}</p>
</div>
<div class="plan-select">
<span>{{ t('plan') }}</span>
<n-select
v-model:value="selectedPlan"
:options="planOptions"
:placeholder="t('plan_placeholder')"
:disabled="dbVersionData.need_initialization"
:loading="savingPlan"
@update:value="savePlan"
/>
</div>
</div>
<n-grid cols="1 s:2" responsive="screen" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="storage-stat">
<span>{{ t('current_database_size') }}</span>
<strong>{{ formatBytes(dbVersionData.database_size) }}</strong>
</div>
</n-grid-item>
<n-grid-item>
<div class="storage-stat">
<span>{{ t('single_database_limit') }}</span>
<strong>{{ selectedPlanDetails ? formatBytes(selectedPlanDetails.databaseLimit) : '—' }}</strong>
</div>
</n-grid-item>
</n-grid>
<div v-if="selectedPlanDetails" class="storage-progress">
<div class="storage-progress-label">
<span>{{ t('storage_usage') }}</span>
<span>{{ storagePercentage.toFixed(2) }}%</span>
</div>
<n-progress
type="line"
:percentage="progressPercentage"
:status="progressStatus"
:show-indicator="false"
/>
</div>
<n-alert class="storage-tip" type="default" :show-icon="false" :bordered="false">
{{ t('storage_tip') }}
</n-alert>
</div>
</n-card>
</div>
</template>
@@ -100,4 +223,88 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
}
.storage-panel {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--n-border-color);
text-align: left;
}
.storage-heading {
display: flex;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
}
.storage-heading h3,
.storage-heading p {
margin: 0;
}
.storage-heading p {
margin-top: 4px;
color: var(--n-text-color-3);
}
.plan-select {
width: 220px;
}
.plan-select > span {
display: block;
margin-bottom: 6px;
color: var(--n-text-color-2);
}
.storage-stat {
display: flex;
min-height: 72px;
padding: 14px;
box-sizing: border-box;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--n-border-color);
border-radius: var(--n-border-radius);
}
.storage-stat span {
color: var(--n-text-color-3);
}
.storage-stat strong {
margin-top: 8px;
font-size: 18px;
color: var(--n-text-color);
}
.storage-progress {
margin-top: 16px;
}
.storage-progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
color: var(--n-text-color-2);
}
.storage-tip {
margin-top: 16px;
margin-bottom: 0;
}
@media (max-width: 640px) {
.storage-heading {
align-items: stretch;
flex-direction: column;
gap: 12px;
}
.plan-select {
width: 100%;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More