Compare commits

...
Author SHA1 Message Date
dreamhunter2333 4911b3ec05 test: update user account settings selector 2026-08-19 10:42:40 +08:00
dreamhunter2333 e5c3c7bf71 fix: harden frontend-next mail rendering 2026-08-19 10:34:32 +08:00
dreamhunter2333 2299c56040 feat: add frontend-next mail client 2026-08-19 10:28:25 +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
120 changed files with 13694 additions and 3533 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/
+55 -1
View File
@@ -6,7 +6,61 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.9.0(main)
## v1.11.0(main)
### Features
- feat: |Frontend Next| 新增 `frontend-next` Vite React + shadcn 命令生成组件的非 admin 邮件客户端,按参考稿 B 风格实现创建/恢复地址、收件箱、写信、地址管理、用户账号集成、设置与亮色/暗色切换
- docs: |前端/文档| 统一邮箱地址、用户账号与 Admin 权限的表述,并明确发信权限和额度按邮箱地址独立申请与管理
### Bug Fixes
### Improvements
- 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
+55 -1
View File
@@ -6,7 +6,61 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.9.0(main)
## v1.11.0(main)
### Features
- feat: |Frontend Next| Add a `frontend-next` Vite React non-admin mail client using shadcn CLI-generated components in the reference B style, covering address create/restore, inbox, compose, address management, user account integration, settings, and light/dark theme support
- docs: |Frontend/Docs| Clarify mailbox address, user account, and Admin permission terminology, including address-specific send access and balances
### Bug Fixes
### Improvements
- 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
+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 -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.62.1-noble
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
+1
View File
@@ -23,6 +23,7 @@ DISABLE_ADMIN_PASSWORD_CHECK = true
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}}
"""
+74 -93
View File
@@ -6,14 +6,14 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.6.5",
"nodemailer": "^9.0.4"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
"@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"ws": "^8.21.2"
}
},
"node_modules/@pinojs/redact": {
@@ -23,35 +23,35 @@
"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.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
"playwright": "1.62.1"
},
"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"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -69,13 +69,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.14",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libmime": "5.4.1",
"libqp": "2.1.1"
}
},
@@ -113,9 +113,9 @@
}
},
"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 +129,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.6.5",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.6.5.tgz",
"integrity": "sha512-5XgLcfl6blDju2SP7TgTv4WIlTt7zxXYcfjznJFmfFrogHR2XzHGlS9s2wdqxv9ZJ5CPaRnig7KaK7qNoJPmlg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"@zone-eu/mailsplit": "5.4.14",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libmime": "5.4.1",
"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 +160,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.1",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"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,9 +178,9 @@
"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": "9.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz",
"integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -258,41 +233,41 @@
"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.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"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 +321,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 +353,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.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"dev": true,
"license": "MIT",
"engines": {
+6 -6
View File
@@ -7,13 +7,13 @@
"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.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"ws": "^8.21.2"
},
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.6.5",
"nodemailer": "^9.0.4"
}
}
+28
View File
@@ -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,79 @@
import { test, expect } from '@playwright/test';
import {
WORKER_URL,
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);
}
}
});
});
+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}/admin/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}/admin/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}/admin/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);
}
});
});
+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();
});
});
@@ -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);
}
}
}
});
});
+2 -2
View File
@@ -62,8 +62,8 @@ test.describe('Passkey Browser Flow', () => {
// Wait for user settings to load (shows user email)
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
// === Step 2: Click "User Settings" tab ===
await page.getByText('User Settings').click();
// === Step 2: Click "User Account Settings" tab ===
await page.getByText('User Account Settings').click();
// === Step 3: Create a passkey ===
await page.getByRole('button', { name: 'Create Passkey' }).click();
@@ -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('Mail Box', { 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();
}
}
});
});
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
.env
.env.*
!.env.example
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temp Email Next</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "cloudflare-temp-email-next",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
"@vitejs/plugin-react": "^5.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.3.5"
},
"devDependencies": {
"@types/node": "^26.0.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3"
},
"packageManager": "pnpm@10.10.0"
}
+3210
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
const API_BASE = import.meta.env.VITE_API_BASE || ""
export type DomainOption = {
label: string
value: string
}
export type OpenSettings = {
fetched: boolean
title: string
prefix: string
addressRegex: string
minAddressLen: number
maxAddressLen: number
needAuth: boolean
enableUserCreateEmail: boolean
disableAnonymousUserCreateEmail: boolean
disableCustomAddressName: boolean
enableUserDeleteEmail: boolean
enableSendMail: boolean
enableAddressPassword: boolean
defaultDomains: string[]
randomSubdomainDomains: string[]
domains: DomainOption[]
cfTurnstileSiteKey: string
enableGlobalTurnstileCheck: boolean
}
export type AddressSettings = {
fetched: boolean
address: string
send_balance: number
auto_reply?: unknown
}
export type UserOpenSettings = {
fetched: boolean
enable: boolean
enableMailVerify: boolean
oauth2ClientIDs: { clientID: string; name: string; icon?: string }[]
}
export type UserSettings = {
fetched: boolean
user_email: string
user_id: number
is_admin: boolean
access_token: string | null
new_user_token: string | null
user_role: { domains?: string[] | null; role: string; prefix?: string | null } | null
}
export type BoundAddress = {
id: number | string
name?: string
address?: string
mail_count?: number
send_count?: number
}
export type MailItem = {
id: number | string
source?: string
address?: string
subject?: string
sender?: string
html?: string
message?: string
text?: string
raw?: string
created_at?: string
metadata?: unknown
}
export type MailListResponse = {
results: MailItem[]
count: number
}
export type SendMailPayload = {
from_name: string
to_name: string
to_mail: string
subject: string
is_html: boolean
content: string
}
export const defaultOpenSettings: OpenSettings = {
fetched: false,
title: "",
prefix: "",
addressRegex: "",
minAddressLen: 1,
maxAddressLen: 30,
needAuth: false,
enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableSendMail: false,
enableAddressPassword: false,
defaultDomains: [],
randomSubdomainDomains: [],
domains: [],
cfTurnstileSiteKey: "",
enableGlobalTurnstileCheck: false,
}
export const defaultAddressSettings: AddressSettings = {
fetched: false,
address: "",
send_balance: 0,
}
export const defaultUserOpenSettings: UserOpenSettings = {
fetched: false,
enable: false,
enableMailVerify: false,
oauth2ClientIDs: [],
}
export const defaultUserSettings: UserSettings = {
fetched: false,
user_email: "",
user_id: 0,
is_admin: false,
access_token: null,
new_user_token: null,
user_role: null,
}
const hasControlChar = (value: string) => {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 32 || code === 127) return true
}
return false
}
const safeHeaderValue = (value: string | null | undefined) => {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined
if (hasControlChar(trimmed)) return undefined
return trimmed
}
const safeBearerHeader = (jwt: string | null | undefined) => {
const safe = safeHeaderValue(jwt)
return safe ? `Bearer ${safe}` : undefined
}
const normalizeOpenSettings = (payload: Record<string, unknown>): OpenSettings => {
const domains = Array.isArray(payload.domains) ? (payload.domains as string[]) : []
const domainLabels = Array.isArray(payload.domainLabels) ? (payload.domainLabels as string[]) : []
return {
...defaultOpenSettings,
fetched: true,
title: String(payload.title || ""),
prefix: String(payload.prefix || ""),
addressRegex: String(payload.addressRegex || ""),
minAddressLen: Number(payload.minAddressLen ?? 1),
maxAddressLen: Number(payload.maxAddressLen ?? 30),
needAuth: Boolean(payload.needAuth),
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
disableCustomAddressName: Boolean(payload.disableCustomAddressName),
enableUserDeleteEmail: Boolean(payload.enableUserDeleteEmail),
enableSendMail: Boolean(payload.enableSendMail),
enableAddressPassword: Boolean(payload.enableAddressPassword),
defaultDomains: Array.isArray(payload.defaultDomains) ? (payload.defaultDomains as string[]) : [],
randomSubdomainDomains: Array.isArray(payload.randomSubdomainDomains)
? (payload.randomSubdomainDomains as string[])
: [],
domains: domains.map((domain, index) => ({
label: domainLabels[index] || domain,
value: domain,
})),
cfTurnstileSiteKey: String(payload.cfTurnstileSiteKey || ""),
enableGlobalTurnstileCheck: Boolean(payload.enableGlobalTurnstileCheck),
}
}
export async function hashPassword(password: string) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password))
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
export function parseJwtAddress(jwt: string) {
try {
const payload = JSON.parse(decodeURIComponent(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))))
return typeof payload.address === "string" ? payload.address : ""
} catch {
return ""
}
}
export function formatDate(value?: string) {
if (!value) return ""
const date = new Date(`${value} UTC`)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
export class ApiClient {
constructor(
private readonly getJwt: () => string,
private readonly getCustomAuth: () => string,
private readonly getUserJwt: () => string,
) {}
async request<T>(path: string, options: RequestInit = {}) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-lang": "en",
}
const customAuth = safeHeaderValue(this.getCustomAuth())
const userJwt = safeHeaderValue(this.getUserJwt())
const authorization = safeBearerHeader(this.getJwt())
if (customAuth) headers["x-custom-auth"] = customAuth
if (userJwt) headers["x-user-token"] = userJwt
if (authorization) headers.Authorization = authorization
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...headers,
...(options.headers as Record<string, string> | undefined),
},
})
const text = await response.text()
const data = text ? tryParseJson(text) : null
if (response.status >= 300) {
throw new Error(typeof data === "string" ? data : text || `[${response.status}] request failed`)
}
return data as T
}
async getOpenSettings() {
return normalizeOpenSettings(await this.request<Record<string, unknown>>("/open_api/settings"))
}
async getSettings() {
if (!safeHeaderValue(this.getJwt())) return { ...defaultAddressSettings, fetched: true }
const payload = await this.request<Record<string, unknown>>("/api/settings")
return {
fetched: true,
address: String(payload.address || ""),
send_balance: Number(payload.send_balance || 0),
auto_reply: payload.auto_reply,
} satisfies AddressSettings
}
async getUserOpenSettings() {
return {
...defaultUserOpenSettings,
...(await this.request<Partial<UserOpenSettings>>("/user_api/open_settings")),
fetched: true,
} satisfies UserOpenSettings
}
async getUserSettings() {
if (!safeHeaderValue(this.getUserJwt())) return { ...defaultUserSettings, fetched: true }
return {
...defaultUserSettings,
...(await this.request<Partial<UserSettings>>("/user_api/settings")),
fetched: true,
} satisfies UserSettings
}
async userLogin(email: string, password: string, cfToken: string) {
const response = await this.request<{ jwt: string }>("/user_api/login", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
cf_token: cfToken,
}),
})
return response.jwt
}
async sendUserVerifyCode(email: string, cfToken: string) {
return this.request<{ expirationTtl?: number }>("/user_api/verify_code", {
method: "POST",
body: JSON.stringify({ email, cf_token: cfToken }),
})
}
async userRegister(email: string, password: string, code: string, cfToken: string) {
await this.request("/user_api/register", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
code,
cf_token: cfToken,
}),
})
}
async listBoundAddresses() {
const response = await this.request<{ results: BoundAddress[] }>("/user_api/bind_address")
return response.results || []
}
async bindCurrentAddress() {
await this.request("/user_api/bind_address", { method: "POST" })
}
async getBoundAddressJwt(addressId: string | number) {
const response = await this.request<{ jwt: string }>(`/user_api/bind_address_jwt/${addressId}`)
return response.jwt
}
async unbindAddress(addressId: string | number) {
await this.request("/user_api/unbind_address", {
method: "POST",
body: JSON.stringify({ address_id: addressId }),
})
}
async createAddress(name: string, domain: string, cfToken: string, enableRandomSubdomain: boolean) {
return this.request<{ jwt: string; password?: string }>("/api/new_address", {
method: "POST",
body: JSON.stringify({
name,
domain,
cf_token: cfToken,
enableRandomSubdomain,
}),
})
}
async credentialLogin(credential: string, cfToken: string) {
await this.request("/open_api/credential_login", {
method: "POST",
body: JSON.stringify({ credential, cf_token: cfToken }),
})
return credential
}
async passwordLogin(email: string, password: string, cfToken: string) {
const response = await this.request<{ jwt: string }>("/api/address_login", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
cf_token: cfToken,
}),
})
return response.jwt
}
async listMails(limit: number, offset: number) {
return this.request<MailListResponse>(`/api/parsed_mails?limit=${limit}&offset=${offset}`)
}
async deleteMail(id: string | number) {
await this.request(`/api/mails/${id}`, { method: "DELETE" })
}
async sendMail(payload: SendMailPayload) {
await this.request("/api/send_mail", {
method: "POST",
body: JSON.stringify(payload),
})
}
async requestSendAccess() {
await this.request("/api/request_send_mail_access", {
method: "POST",
body: JSON.stringify({}),
})
}
async clearInbox() {
await this.request("/api/clear_inbox", { method: "DELETE" })
}
async clearSentItems() {
await this.request("/api/clear_sent_items", { method: "DELETE" })
}
async deleteAddress() {
await this.request("/api/delete_address", { method: "DELETE" })
}
}
function tryParseJson(value: string) {
try {
return JSON.parse(value)
} catch {
return value
}
}
@@ -0,0 +1,98 @@
import { useEffect, useId, useRef, useState } from "react"
import { Button } from "./ui/button"
declare global {
interface Window {
turnstile?: {
render: (
selector: string,
options: {
sitekey: string
theme: "light" | "dark"
callback: (token: string) => void
},
) => string
remove: (id: string) => void
}
}
}
type TurnstileWidgetProps = {
siteKey: string
theme: "light" | "dark"
onToken: (token: string) => void
}
let scriptPromise: Promise<void> | null = null
function loadTurnstileScript() {
if (window.turnstile) return Promise.resolve()
if (scriptPromise) return scriptPromise
scriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]")
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true })
existing.addEventListener("error", () => reject(new Error("Turnstile script failed")), { once: true })
return
}
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.dataset.turnstile = "true"
script.onload = () => resolve()
script.onerror = () => reject(new Error("Turnstile script failed"))
document.head.appendChild(script)
})
return scriptPromise
}
export function TurnstileWidget({ siteKey, theme, onToken }: TurnstileWidgetProps) {
const id = `turnstile-${useId().replace(/:/g, "")}`
const widgetId = useRef("")
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!siteKey) return undefined
let mounted = true
const renderWidget = async () => {
setFailed(false)
onToken("")
try {
await loadTurnstileScript()
if (!mounted || !window.turnstile) return
if (widgetId.current) window.turnstile.remove(widgetId.current)
widgetId.current = window.turnstile.render(`#${id}`, {
sitekey: siteKey,
theme,
callback: onToken,
})
} catch {
if (mounted) setFailed(true)
}
}
renderWidget()
return () => {
mounted = false
if (widgetId.current && window.turnstile) {
window.turnstile.remove(widgetId.current)
widgetId.current = ""
}
}
}, [id, onToken, siteKey, theme])
if (!siteKey) return null
return (
<div className="turnstile-box">
<div id={id} />
{failed && (
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Reload challenge
</Button>
)}
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,30 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,255 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,56 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+91
View File
@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App"
import "./index.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import path from "node:path"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": "http://127.0.0.1:8787",
"/open_api": "http://127.0.0.1:8787",
"/user_api": "http://127.0.0.1:8787",
"/telegram": "http://127.0.0.1:8787",
},
},
})
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.9.0",
"version": "1.11.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.19.0",
"dompurify": "^3.4.13",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.4",
"postal-mime": "^2.7.5",
"vooks": "^0.2.12",
"vue": "^3.5.35",
"vue": "^3.5.41",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.4.4",
"vue-i18n": "^11.4.8",
"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.10",
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1",
"wrangler": "^4.96.0"
"wrangler": "^4.119.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+1095 -1391
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -5,6 +5,7 @@ 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'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
@@ -123,7 +124,7 @@ const getOpenSettings = async (message, notification) => {
notification.info({
content: () => {
return h("div", {
innerHTML: announcement.value
innerHTML: sanitizeHtml(announcement.value)
});
}
});
+1 -1
View File
@@ -97,7 +97,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 -13
View File
@@ -60,7 +60,7 @@ const props = defineProps({
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 +71,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() === '') {
@@ -183,7 +189,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) {
@@ -202,6 +208,11 @@ const backFirstPageAndRefresh = async () => {
const clickRow = async (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
curMail.value = row;
return;
}
if (mailListView.value && curMail.value?.id === row.id) {
curMail.value = null;
return;
}
curMail.value = row;
@@ -375,8 +386,13 @@ 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>
@@ -412,15 +428,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>
@@ -446,6 +472,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)">
<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;">
@@ -555,8 +623,80 @@ 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;
}
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')
@@ -58,6 +59,26 @@ const curAttachments = ref([]);
const attachmentLoding = 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();
};
@@ -149,17 +170,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 +204,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 +251,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>
+28 -24
View File
@@ -5,10 +5,10 @@ export const deMessages = {
"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.Admin.account": "E-Mail-Adressen",
"views.Index.accountSettings": "E-Mail-Adress-Einstellungen",
"views.Admin.account_settings": "E-Mail-Adress-Einstellungen",
"views.index.SimpleIndex.accountSettings": "E-Mail-Adress-Einstellungen",
"views.index.Attachment.action": "Aktion",
"views.admin.SenderAccess.action": "Aktion",
"views.user.UserSettings.actions": "Aktionen",
@@ -60,13 +60,13 @@ export const deMessages = {
"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.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.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen?",
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"views.admin.AccountSettings.delete_rule_confirm": "Möchtest du diese Regel wirklich löschen?",
"views.admin.UserManagement.deleteUserTip": "Möchtest du diesen Benutzer wirklich löschen?",
"views.admin.UserManagement.deleteUserTip": "Möchtest du dieses Benutzerkonto wirklich löschen?",
"views.Admin.logoutConfirmContent": "Möchtest du dich wirklich aus dem Admin-Bereich abmelden?",
"views.user.UserSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN-Organisationssperrliste",
@@ -74,6 +74,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",
@@ -109,7 +110,7 @@ export const deMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "Inaktive Adressen löschen, die älter als n Tage sind",
"views.admin.Maintenance.mailBoxLabel": "Posteingänge löschen, die älter als n Tage sind",
"views.admin.Maintenance.sendBoxLabel": "Postausgänge löschen, die älter als n Tage sind",
"views.admin.Maintenance.unboundAddressLabel": "Nicht verknüpfte Adressen löschen, die älter als n Tage sind",
"views.admin.Maintenance.unboundAddressLabel": "Seit n Tagen nicht verknüpfte E-Mail-Adressen löschen",
"views.admin.Maintenance.mailUnknowLabel": "E-Mails mit unbekanntem Empfänger löschen, die älter als n Tage sind",
"views.index.AccountSettings.clearInbox": "Posteingang leeren",
"views.admin.Account.clearInbox": "Posteingang leeren",
@@ -133,20 +134,20 @@ 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.account_create": "E-Mail-Adresse erstellen",
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
"views.user.UserSettings.createPasskey": "Passkey erstellen",
"views.admin.UserManagement.createUser": "Benutzer erstellen",
"views.admin.UserManagement.createUser": "Benutzerkonto erstellen",
"views.user.UserSettings.created_at": "Erstellt am",
"views.admin.Account.created_at": "Erstellt am",
"views.admin.SenderAccess.created_at": "Erstellt am",
"views.admin.UserManagement.created_at": "Erstellt am",
"views.common.Login.credentialLogin": "Mit Zugangsdaten anmelden",
"views.admin.DatabaseManager.current_db_version": "Aktuelle DB-Version",
"views.user.UserBar.currentUser": "Aktuell angemeldeter Benutzer",
"views.user.UserBar.currentUser": "Aktuelles Benutzerkonto",
"views.admin.UserManagement.roleDonotExist": "Die aktuelle Rolle existiert nicht",
"views.admin.Maintenance.customSqlCleanup": "Benutzerdefinierte SQL-Bereinigung",
"views.admin.AccountSettings.send_mail_daily_limit": "Tageslimit",
@@ -169,11 +170,11 @@ 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.deleteAccount": "E-Mail-Adresse löschen",
"views.admin.Account.deleteAccount": "E-Mail-Adresse löschen",
"views.user.UserSettings.deletePasskey": "Passkey löschen",
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
"views.admin.UserManagement.deleteUser": "Benutzerkonto löschen",
"views.index.Attachment.deleteSuccess": "Erfolgreich gelöscht",
"views.admin.SenderAccess.disable": "Deaktivieren",
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
@@ -206,7 +207,7 @@ export const deMessages = {
"views.admin.Telegram.enable": "Aktivieren",
"views.admin.UserSettings.enable": "Aktivieren",
"views.admin.AiExtractSettings.enableAllowList": "Aktivieren Adressfreigabeliste",
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte Benutzer beschränken)",
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte E-Mail-Adressen beschränken)",
"views.index.AutoReply.enableAutoReply": "Automatische Antwort aktivieren",
"views.admin.Maintenance.cronTip": "Um die Cron-Bereinigung zu aktivieren, konfiguriere [crons] im Worker. Siehe Dokumentation; 0 Tage bedeutet alles löschen.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Aktivieren Tägliches Anfrage-Limit",
@@ -299,7 +300,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",
@@ -330,7 +334,7 @@ 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": "Sendezugang und Guthaben gehören zur aktuellen E-Mail-Adresse, nicht zum Benutzerkonto. Für diese Adresse Zugriff anfordern oder den Administrator kontaktieren.",
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
@@ -416,7 +420,7 @@ export const deMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "Ersetzungsvorlage",
"components.MailBox.reply": "Antworten",
"components.MailContentRenderer.reply": "Antworten",
"views.index.SendMail.requestAccess": "Zugriff anfordern",
"views.index.SendMail.requestAccess": "Zugriff für diese Adresse anfordern",
"views.user.UserLogin.resetPassword": "Zurücksetzen Passwort",
"views.admin.Account.resetPassword": "Zurücksetzen Passwort",
"views.admin.UserManagement.resetPassword": "Zurücksetzen Passwort",
@@ -552,15 +556,15 @@ export const deMessages = {
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
"views.Header.user": "Benutzer",
"views.Admin.user": "Benutzer",
"components.AddressSelect.userAddresses": "Benutzeradressen",
"views.Admin.user": "Benutzerkonten",
"components.AddressSelect.userAddresses": "Mit Benutzerkonto verknüpfte Adressen",
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
"views.admin.Statistics.userCount": "Benutzeranzahl",
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
"views.Admin.user_management": "Benutzerverwaltung",
"views.Admin.user_settings": "Benutzereinstellungen",
"views.User.user_settings": "Benutzereinstellungen",
"views.admin.UserManagement.user_email": "E-Mail des Benutzerkontos",
"views.index.AddressBar.userLogin": "Benutzerkonto-Anmeldung",
"views.Admin.user_management": "Benutzerkonten verwalten",
"views.Admin.user_settings": "Benutzerkonto-Einstellungen",
"views.User.user_settings": "Benutzerkonto-Einstellungen",
"components.AiExtractInfo.authCode": "Bestätigungscode",
"views.user.UserLogin.verifyCode": "Bestätigungscode",
"views.user.UserLogin.verifyCodeSent": "Bestätigungscode gesendet, läuft ab in {timeout} Sekunden",
+28 -24
View File
@@ -5,10 +5,10 @@ export const esMessages = {
"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.Admin.account": "Direcciones de correo",
"views.Index.accountSettings": "Configuración de la dirección",
"views.Admin.account_settings": "Configuración de direcciones",
"views.index.SimpleIndex.accountSettings": "Configuración de la dirección",
"views.index.Attachment.action": "Acción",
"views.admin.SenderAccess.action": "Acción",
"views.user.UserSettings.actions": "Acciones",
@@ -60,13 +60,13 @@ export const esMessages = {
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
"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.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección y todos sus correos?",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"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?",
"views.admin.AccountSettings.delete_rule_confirm": "¿Seguro que quieres eliminar esta regla?",
"views.admin.UserManagement.deleteUserTip": "¿Seguro que quieres eliminar este usuario?",
"views.admin.UserManagement.deleteUserTip": "¿Seguro que quieres eliminar esta cuenta de usuario?",
"views.Admin.logoutConfirmContent": "¿Seguro que quieres salir del panel de administración?",
"views.user.UserSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizaciones ASN",
@@ -74,6 +74,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",
@@ -109,7 +110,7 @@ export const esMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "Limpiar las direcciones inactivas de hace más de n días",
"views.admin.Maintenance.mailBoxLabel": "Limpiar la bandeja de entrada de hace más de n días",
"views.admin.Maintenance.sendBoxLabel": "Limpiar la bandeja de salida de hace más de n días",
"views.admin.Maintenance.unboundAddressLabel": "Limpiar las direcciones no vinculadas de hace más de n días",
"views.admin.Maintenance.unboundAddressLabel": "Limpiar direcciones desvinculadas desde hace n días",
"views.admin.Maintenance.mailUnknowLabel": "Limpiar los correos con destinatario desconocido de hace más de n días",
"views.index.AccountSettings.clearInbox": "Vaciar bandeja de entrada",
"views.admin.Account.clearInbox": "Vaciar bandeja de entrada",
@@ -133,20 +134,20 @@ 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.account_create": "Crear dirección de correo",
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo 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",
"views.user.UserSettings.createPasskey": "Crear passkey",
"views.admin.UserManagement.createUser": "Crear usuario",
"views.admin.UserManagement.createUser": "Crear cuenta de usuario",
"views.user.UserSettings.created_at": "Creado el",
"views.admin.Account.created_at": "Creado el",
"views.admin.SenderAccess.created_at": "Creado el",
"views.admin.UserManagement.created_at": "Creado el",
"views.common.Login.credentialLogin": "Inicio de sesión con credencial",
"views.admin.DatabaseManager.current_db_version": "Versión actual de la BD",
"views.user.UserBar.currentUser": "Usuario actual",
"views.user.UserBar.currentUser": "Cuenta de usuario actual",
"views.admin.UserManagement.roleDonotExist": "El rol actual no existe",
"views.admin.Maintenance.customSqlCleanup": "Limpieza SQL personalizada",
"views.admin.AccountSettings.send_mail_daily_limit": "Límite diario",
@@ -169,11 +170,11 @@ 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.deleteAccount": "Eliminar dirección de correo",
"views.admin.Account.deleteAccount": "Eliminar dirección de correo",
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
"views.admin.UserManagement.deleteUser": "Eliminar cuenta de usuario",
"views.index.Attachment.deleteSuccess": "Eliminado correctamente",
"views.admin.SenderAccess.disable": "Deshabilitar",
"views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada",
@@ -206,7 +207,7 @@ export const esMessages = {
"views.admin.Telegram.enable": "Habilitar",
"views.admin.UserSettings.enable": "Habilitar",
"views.admin.AiExtractSettings.enableAllowList": "Habilitar Lista blanca de direcciones",
"views.admin.Webhook.enableAllowList": "Habilitar lista de permitidos (restringe el acceso del webhook a usuarios específicos)",
"views.admin.Webhook.enableAllowList": "Habilitar lista de permitidos (restringe el webhook a direcciones específicas)",
"views.index.AutoReply.enableAutoReply": "Habilitar respuesta automática",
"views.admin.Maintenance.cronTip": "Para activar la limpieza por cron, configura [crons] en el worker. Consulta la documentación; 0 días significa limpiar todo.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Habilitar Límite diario de solicitudes",
@@ -299,7 +300,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",
@@ -330,7 +334,7 @@ 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 acceso y el saldo de envío pertenecen a la dirección actual, no a la cuenta de usuario. Solicita acceso para esta dirección o contacta con el administrador.",
"components.SendBox.emptySent": "No hay correos enviados",
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
@@ -416,7 +420,7 @@ export const esMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "Plantilla de reemplazo",
"components.MailBox.reply": "Responder",
"components.MailContentRenderer.reply": "Responder",
"views.index.SendMail.requestAccess": "Solicitar acceso",
"views.index.SendMail.requestAccess": "Solicitar acceso para esta dirección",
"views.user.UserLogin.resetPassword": "Restablecer Contraseña",
"views.admin.Account.resetPassword": "Restablecer Contraseña",
"views.admin.UserManagement.resetPassword": "Restablecer Contraseña",
@@ -552,15 +556,15 @@ export const esMessages = {
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
"views.Header.user": "Usuario",
"views.Admin.user": "Usuario",
"components.AddressSelect.userAddresses": "Direcciones del usuario",
"views.Admin.user": "Cuentas de usuario",
"components.AddressSelect.userAddresses": "Direcciones vinculadas a la cuenta",
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
"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.Admin.user_management": "Gestión de usuarios",
"views.Admin.user_settings": "Configuración de usuario",
"views.User.user_settings": "Configuración de usuario",
"views.admin.UserManagement.user_email": "Correo de la cuenta de usuario",
"views.index.AddressBar.userLogin": "Inicio de sesión de la cuenta",
"views.Admin.user_management": "Gestión de cuentas de usuario",
"views.Admin.user_settings": "Configuración de cuentas de usuario",
"views.User.user_settings": "Configuración de la cuenta de usuario",
"components.AiExtractInfo.authCode": "Código de verificación",
"views.user.UserLogin.verifyCode": "Código de verificación",
"views.user.UserLogin.verifyCodeSent": "Código de verificación enviado, expira en {timeout} segundos",
+28 -24
View File
@@ -5,10 +5,10 @@ export const jaMessages = {
"views.Index.about": "概要",
"views.Admin.about": "概要",
"views.Header.accessHeader": "アクセス用パスワード",
"views.Admin.account": "アカウント",
"views.Index.accountSettings": "アカウント設定",
"views.Admin.account_settings": "アカウント設定",
"views.index.SimpleIndex.accountSettings": "アカウント設定",
"views.Admin.account": "メールアドレス",
"views.Index.accountSettings": "メールアドレス設定",
"views.Admin.account_settings": "メールアドレス設定",
"views.index.SimpleIndex.accountSettings": "メールアドレス設定",
"views.index.Attachment.action": "操作",
"views.admin.SenderAccess.action": "操作",
"views.user.UserSettings.actions": "操作",
@@ -60,13 +60,13 @@ export const jaMessages = {
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
"views.admin.AccountSettings.delete_rule_confirm": "このルールを削除してもよろしいですか?",
"views.admin.UserManagement.deleteUserTip": "このユーザーを削除してもよろしいですか?",
"views.admin.UserManagement.deleteUserTip": "このユーザーアカウントを削除してもよろしいですか?",
"views.Admin.logoutConfirmContent": "管理画面からログアウトしてもよろしいですか?",
"views.user.UserSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN組織ブラックリスト",
@@ -74,6 +74,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": "自動返信",
@@ -109,7 +110,7 @@ export const jaMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "n 日より前の非アクティブなアドレスを削除",
"views.admin.Maintenance.mailBoxLabel": "n 日より前の受信箱を削除",
"views.admin.Maintenance.sendBoxLabel": "n 日より前の送信箱を削除",
"views.admin.Maintenance.unboundAddressLabel": "n 日より前の未紐付けアドレスを削除",
"views.admin.Maintenance.unboundAddressLabel": "紐付け解除から n 日経過したメールアドレスを削除",
"views.admin.Maintenance.mailUnknowLabel": "n 日より前の受信者不明メールを削除",
"views.index.AccountSettings.clearInbox": "受信箱を削除",
"views.admin.Account.clearInbox": "受信箱を削除",
@@ -133,20 +134,20 @@ export const jaMessages = {
"views.index.SimpleIndex.copyAddress": "コピー",
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
"views.Footer.copyright": "著作権",
"views.Admin.account_create": "アカウントを作成",
"views.Admin.account_create": "メールアドレスを作成",
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
"views.common.Login.getNewEmail": "新しいメールを作成",
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
"views.user.UserSettings.createPasskey": "パスキーを作成",
"views.admin.UserManagement.createUser": "ユーザーを作成",
"views.admin.UserManagement.createUser": "ユーザーアカウントを作成",
"views.user.UserSettings.created_at": "作成日時",
"views.admin.Account.created_at": "作成日時",
"views.admin.SenderAccess.created_at": "作成日時",
"views.admin.UserManagement.created_at": "作成日時",
"views.common.Login.credentialLogin": "資格情報でログイン",
"views.admin.DatabaseManager.current_db_version": "現在のDBバージョン",
"views.user.UserBar.currentUser": "現在のログインユーザー",
"views.user.UserBar.currentUser": "現在のユーザーアカウント",
"views.admin.UserManagement.roleDonotExist": "現在のロールは存在しません",
"views.admin.Maintenance.customSqlCleanup": "カスタムSQLクリーンアップ",
"views.admin.AccountSettings.send_mail_daily_limit": "日次上限",
@@ -169,11 +170,11 @@ 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.deleteAccount": "メールアドレスを削除",
"views.admin.Account.deleteAccount": "メールアドレスを削除",
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
"views.admin.AccountSettings.delete_success": "削除しました",
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
"views.admin.UserManagement.deleteUser": "ユーザーアカウントを削除",
"views.index.Attachment.deleteSuccess": "正常に削除しました",
"views.admin.SenderAccess.disable": "無効化",
"views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化",
@@ -206,7 +207,7 @@ export const jaMessages = {
"views.admin.Telegram.enable": "有効化",
"views.admin.UserSettings.enable": "有効化",
"views.admin.AiExtractSettings.enableAllowList": "アドレス許可リストを有効化",
"views.admin.Webhook.enableAllowList": "許可リストを有効化 (Webhook へのアクセスを特定ユーザーに制限)",
"views.admin.Webhook.enableAllowList": "許可リストを有効化Webhook を特定のメールアドレスに制限",
"views.index.AutoReply.enableAutoReply": "自動返信を有効化",
"views.admin.Maintenance.cronTip": "cron クリーンアップを有効にするには worker の [crons] を設定してください。詳細はドキュメントを参照してください。0 日はすべて削除を意味します。",
"views.admin.IpBlacklistSettings.enable_daily_limit": "1日のリクエスト上限を有効化",
@@ -299,7 +300,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": "メンテナンス",
@@ -330,7 +334,7 @@ export const jaMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
"views.index.SimpleIndex.noMails": "メールが見つかりません",
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者連絡してください。",
"views.index.SendMail.requestAccessTip": "送信権限と残高はユーザーアカウントではなく現在のメールアドレスに属します。このアドレスの権限申請するか、管理者連絡してください。",
"components.SendBox.emptySent": "送信済みメールはありません",
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
"views.Admin.userOauth2Settings": "OAuth2設定",
@@ -416,7 +420,7 @@ export const jaMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "置換テンプレート",
"components.MailBox.reply": "返信",
"components.MailContentRenderer.reply": "返信",
"views.index.SendMail.requestAccess": "アクセスを申請",
"views.index.SendMail.requestAccess": "このアドレスの送信権限を申請",
"views.user.UserLogin.resetPassword": "パスワードをリセット",
"views.admin.Account.resetPassword": "パスワードをリセット",
"views.admin.UserManagement.resetPassword": "パスワードをリセット",
@@ -552,15 +556,15 @@ export const jaMessages = {
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
"views.Header.user": "ユーザー",
"views.Admin.user": "ユーザー",
"components.AddressSelect.userAddresses": "ユーザーアドレス",
"views.Admin.user": "ユーザーアカウント",
"components.AddressSelect.userAddresses": "ユーザーアカウントに紐付くアドレス",
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
"views.admin.Statistics.userCount": "ユーザー数",
"views.admin.UserManagement.user_email": "ユーザーメール",
"views.index.AddressBar.userLogin": "ユーザーログイン",
"views.Admin.user_management": "ユーザー管理",
"views.Admin.user_settings": "ユーザー設定",
"views.User.user_settings": "ユーザー設定",
"views.admin.UserManagement.user_email": "ユーザーアカウントのメール",
"views.index.AddressBar.userLogin": "ユーザーアカウントログイン",
"views.Admin.user_management": "ユーザーアカウント管理",
"views.Admin.user_settings": "ユーザーアカウント設定",
"views.User.user_settings": "ユーザーアカウント設定",
"components.AiExtractInfo.authCode": "認証コード",
"views.user.UserLogin.verifyCode": "認証コード",
"views.user.UserLogin.verifyCodeSent": "認証コードを送信しました, 有効期限 {timeout} 秒",
+28 -24
View File
@@ -5,10 +5,10 @@ export const ptBRMessages = {
"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.Admin.account": "Endereços de e-mail",
"views.Index.accountSettings": "Configurações do endereço",
"views.Admin.account_settings": "Configurações de endereços",
"views.index.SimpleIndex.accountSettings": "Configurações do endereço",
"views.index.Attachment.action": "Ação",
"views.admin.SenderAccess.action": "Ação",
"views.user.UserSettings.actions": "Ações",
@@ -60,13 +60,13 @@ export const ptBRMessages = {
"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.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.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço e todos os e-mails dele?",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"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?",
"views.admin.AccountSettings.delete_rule_confirm": "Tem certeza de que deseja excluir esta regra?",
"views.admin.UserManagement.deleteUserTip": "Tem certeza de que deseja excluir este usuário?",
"views.admin.UserManagement.deleteUserTip": "Tem certeza de que deseja excluir esta conta de usuário?",
"views.Admin.logoutConfirmContent": "Tem certeza de que deseja sair do painel de administração?",
"views.user.UserSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizações ASN",
@@ -74,6 +74,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",
@@ -109,7 +110,7 @@ export const ptBRMessages = {
"views.admin.Maintenance.inactiveAddressLabel": "Limpar os endereços inativos de mais de n dias",
"views.admin.Maintenance.mailBoxLabel": "Limpar a caixa de entrada de mais de n dias",
"views.admin.Maintenance.sendBoxLabel": "Limpar a caixa de saída de mais de n dias",
"views.admin.Maintenance.unboundAddressLabel": "Limpar os endereços desvinculados de mais de n dias",
"views.admin.Maintenance.unboundAddressLabel": "Limpar endereços desvinculados n dias",
"views.admin.Maintenance.mailUnknowLabel": "Limpar os e-mails com destinatário desconhecido de mais de n dias",
"views.index.AccountSettings.clearInbox": "Limpar caixa de entrada",
"views.admin.Account.clearInbox": "Limpar caixa de entrada",
@@ -133,20 +134,20 @@ 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.account_create": "Criar endereço de e-mail",
"views.admin.CreateAccount.creatNewEmail": "Criar novo 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",
"views.user.UserSettings.createPasskey": "Criar passkey",
"views.admin.UserManagement.createUser": "Criar usuário",
"views.admin.UserManagement.createUser": "Criar conta de usuário",
"views.user.UserSettings.created_at": "Criado em",
"views.admin.Account.created_at": "Criado em",
"views.admin.SenderAccess.created_at": "Criado em",
"views.admin.UserManagement.created_at": "Criado em",
"views.common.Login.credentialLogin": "Login com credencial",
"views.admin.DatabaseManager.current_db_version": "Versão atual do banco",
"views.user.UserBar.currentUser": "Usuário atual",
"views.user.UserBar.currentUser": "Conta de usuário atual",
"views.admin.UserManagement.roleDonotExist": "A função atual não existe",
"views.admin.Maintenance.customSqlCleanup": "Limpeza SQL personalizada",
"views.admin.AccountSettings.send_mail_daily_limit": "Limite diário",
@@ -169,11 +170,11 @@ 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.deleteAccount": "Excluir endereço de e-mail",
"views.admin.Account.deleteAccount": "Excluir endereço de e-mail",
"views.user.UserSettings.deletePasskey": "Excluir passkey",
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
"views.admin.UserManagement.deleteUser": "Excluir usuário",
"views.admin.UserManagement.deleteUser": "Excluir conta de usuário",
"views.index.Attachment.deleteSuccess": "Excluído com sucesso",
"views.admin.SenderAccess.disable": "Desativar",
"views.Admin.loginViaDisabledCheck": "Verificação de senha desativada",
@@ -206,7 +207,7 @@ export const ptBRMessages = {
"views.admin.Telegram.enable": "Ativar",
"views.admin.UserSettings.enable": "Ativar",
"views.admin.AiExtractSettings.enableAllowList": "Ativar Lista branca de endereços",
"views.admin.Webhook.enableAllowList": "Ativar lista de permissão (restringir o acesso ao webhook a usuários específicos)",
"views.admin.Webhook.enableAllowList": "Ativar lista de permissão (restringir o webhook a endereços específicos)",
"views.index.AutoReply.enableAutoReply": "Ativar resposta automática",
"views.admin.Maintenance.cronTip": "Para ativar a limpeza por cron, configure [crons] no worker. Consulte a documentação; 0 dias significa limpar tudo.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Ativar limite diário de solicitações",
@@ -299,7 +300,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",
@@ -330,7 +334,7 @@ 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": "O acesso e o saldo de envio pertencem ao endereço atual, não à conta de usuário. Solicite acesso para este endereço ou fale com o administrador.",
"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",
@@ -416,7 +420,7 @@ export const ptBRMessages = {
"views.admin.UserOauth2Settings.userEmailReplace": "Modelo de substituição",
"components.MailBox.reply": "Responder",
"components.MailContentRenderer.reply": "Responder",
"views.index.SendMail.requestAccess": "Solicitar acesso",
"views.index.SendMail.requestAccess": "Solicitar acesso para este endereço",
"views.user.UserLogin.resetPassword": "Redefinir Senha",
"views.admin.Account.resetPassword": "Redefinir Senha",
"views.admin.UserManagement.resetPassword": "Redefinir Senha",
@@ -552,15 +556,15 @@ export const ptBRMessages = {
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
"views.common.Appearance.useUTCDate": "Usar data UTC",
"views.Header.user": "Usuário",
"views.Admin.user": "Usuário",
"components.AddressSelect.userAddresses": "Endereços do usuário",
"views.Admin.user": "Contas de usuário",
"components.AddressSelect.userAddresses": "Endereços vinculados à conta",
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
"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.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",
"views.admin.UserManagement.user_email": "E-mail da conta de usuário",
"views.index.AddressBar.userLogin": "Login da conta de usuário",
"views.Admin.user_management": "Gerenciamento de contas de usuário",
"views.Admin.user_settings": "Configurações de contas de usuário",
"views.User.user_settings": "Configurações da conta de usuário",
"components.AiExtractInfo.authCode": "Código de verificação",
"views.user.UserLogin.verifyCode": "Código de verificação",
"views.user.UserLogin.verifyCodeSent": "Código de verificação enviado, expira em {timeout} segundos",
+90 -58
View File
@@ -42,6 +42,10 @@ export const MESSAGE_REGISTRY = {
"en": "Auto Refresh",
"zh": "自动刷新"
},
"backToList": {
"en": "Back to List",
"zh": "返回列表"
},
"cancelMultiAction": {
"en": "Cancel Multi Action",
"zh": "取消多选"
@@ -186,6 +190,14 @@ export const MESSAGE_REGISTRY = {
"en": "Fullscreen",
"zh": "全屏"
},
"loadRemoteImages": {
"en": "Load Images",
"zh": "加载图片"
},
"remoteImagesBlocked": {
"en": "{count} remote resources blocked to protect your privacy",
"zh": "已阻止 {count} 项外部资源以保护隐私"
},
"reply": {
"en": "Reply",
"zh": "回复"
@@ -277,8 +289,8 @@ export const MESSAGE_REGISTRY = {
"zh": "本地地址"
},
"userAddresses": {
"en": "User Addresses",
"zh": "用户地址"
"en": "Addresses Bound to User Account",
"zh": "用户账号绑定地址"
}
},
"components.AddressCredentialModal": {
@@ -407,8 +419,8 @@ export const MESSAGE_REGISTRY = {
"zh": "关于"
},
"accountSettings": {
"en": "Account Settings",
"zh": "账户"
"en": "Mailbox Address Settings",
"zh": "邮箱地址设置"
},
"appearance": {
"en": "Appearance",
@@ -553,8 +565,8 @@ export const MESSAGE_REGISTRY = {
"zh": "Cloudflare 临时邮件"
},
"user": {
"en": "User",
"zh": "用户"
"en": "User Account",
"zh": "用户账号"
}
},
"views.user.BindAddress": {
@@ -577,16 +589,16 @@ export const MESSAGE_REGISTRY = {
"zh": "请输入 Admin 密码"
},
"account": {
"en": "Account",
"zh": "账号"
"en": "Mailbox Addresses",
"zh": "邮箱地址"
},
"account_create": {
"en": "Create Account",
"zh": "创建账号"
"en": "Create Mailbox Address",
"zh": "创建邮箱地址"
},
"account_settings": {
"en": "Account Settings",
"zh": "账号设置"
"en": "Mailbox Address Settings",
"zh": "邮箱地址设置"
},
"adminAccount": {
"en": "Admin",
@@ -693,20 +705,20 @@ export const MESSAGE_REGISTRY = {
"zh": "无收件人邮件"
},
"user": {
"en": "User",
"zh": "用户"
"en": "User Accounts",
"zh": "用户账号"
},
"userOauth2Settings": {
"en": "Oauth2 Settings",
"zh": "Oauth2 设置"
},
"user_management": {
"en": "User Management",
"zh": "用户管理"
"en": "User Account Management",
"zh": "用户账号管理"
},
"user_settings": {
"en": "User Settings",
"zh": "用户设置"
"en": "User Account Settings",
"zh": "用户账号设置"
},
"webhookSettings": {
"en": "Webhook Settings",
@@ -731,8 +743,8 @@ export const MESSAGE_REGISTRY = {
"zh": "收件箱"
},
"user_settings": {
"en": "User Settings",
"zh": "用户设置"
"en": "User Account Settings",
"zh": "用户账号设置"
}
},
"views.user.UserLogin": {
@@ -811,8 +823,8 @@ export const MESSAGE_REGISTRY = {
},
"views.user.UserBar": {
"currentUser": {
"en": "Current Login User",
"zh": "当前登录用户"
"en": "Current User Account",
"zh": "当前用户账号"
},
"fetchUserSettingsError": {
"en": "Login password is invalid or account not exist, it may be network connection issue, please try again later.",
@@ -840,6 +852,10 @@ export const MESSAGE_REGISTRY = {
"en": "Mail Count",
"zh": "邮件数量"
},
"itemCount": {
"en": "Total",
"zh": "总数"
},
"name": {
"en": "Name",
"zh": "名称"
@@ -861,8 +877,8 @@ export const MESSAGE_REGISTRY = {
"zh": "转移地址"
},
"transferAddressTip": {
"en": "Transfer address to another user will remove the address from your account and transfer it to another user. Are you sure to transfer the address?",
"zh": "转移地址到其他用户将会从你的账户中移除此地址并转移给其他用户。确定要转移地址吗?"
"en": "Transferring this address removes it from your user account and binds it to another user account. Are you sure?",
"zh": "转移后,此邮箱地址将从你的用户账号解绑,并绑定到另一个用户账号。确定要转移吗?"
},
"unbindAddress": {
"en": "Unbind Address",
@@ -899,12 +915,12 @@ export const MESSAGE_REGISTRY = {
"zh": "确认密码"
},
"deleteAccount": {
"en": "Delete Account",
"zh": "删除账户"
"en": "Delete Mailbox Address",
"zh": "删除邮箱地址"
},
"deleteAccountConfirm": {
"en": "Are you sure to delete your account and all emails for this account?",
"zh": "确定要删除你的账户和其中的所有邮件吗?"
"en": "Are you sure you want to delete this mailbox address and all of its emails?",
"zh": "确定要删除当前邮箱地址及其全部邮件吗"
},
"logout": {
"en": "Logout",
@@ -1039,12 +1055,12 @@ export const MESSAGE_REGISTRY = {
"zh": "预览"
},
"requestAccess": {
"en": "Request Access",
"zh": "申请权限"
"en": "Request Access for This Address",
"zh": "为当前地址申请发信权限"
},
"requestAccessTip": {
"en": "No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.",
"zh": "当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。"
"en": "Send access and balance belong to the current mailbox address, not the user account. This address has no send balance yet. Request access for it or contact the admin.",
"zh": "发信权限和额度属于当前邮箱地址,不属于用户账号。当前地址还没有可用额度,请为该地址申请发信权限或联系管理员。"
},
"rich text": {
"en": "Rich Text",
@@ -1089,8 +1105,8 @@ export const MESSAGE_REGISTRY = {
},
"views.index.SimpleIndex": {
"accountSettings": {
"en": "Account Settings",
"zh": "账户设置"
"en": "Mailbox Address Settings",
"zh": "邮箱地址设置"
},
"addressCopied": {
"en": "Address copied successfully",
@@ -1181,7 +1197,7 @@ export const MESSAGE_REGISTRY = {
"zh": "邮箱地址凭证"
},
"addressCredentialTip": {
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
"en": "Copy this mailbox address credential to log in to this address.",
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
},
"addressManage": {
@@ -1193,7 +1209,7 @@ export const MESSAGE_REGISTRY = {
"zh": "地址密码"
},
"fetchAddressError": {
"en": "Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.",
"en": "The mailbox address credential is invalid or the address does not exist. This may also be a network issue; please try again later.",
"zh": "邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。"
},
"linkWithAddressCredential": {
@@ -1205,8 +1221,8 @@ export const MESSAGE_REGISTRY = {
"zh": "确定"
},
"userLogin": {
"en": "User Login",
"zh": "用户登录"
"en": "User Account Login",
"zh": "用户账号登录"
}
},
"views.admin.SendBox": {
@@ -1337,7 +1353,7 @@ export const MESSAGE_REGISTRY = {
"zh": "邮箱地址凭证"
},
"addressCredentialTip": {
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
"en": "Copy this mailbox address credential to log in to this address.",
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
},
"addressQueryTip": {
@@ -1369,8 +1385,8 @@ export const MESSAGE_REGISTRY = {
"zh": "删除"
},
"deleteAccount": {
"en": "Delete Account",
"zh": "删除邮箱"
"en": "Delete Mailbox Address",
"zh": "删除邮箱地址"
},
"deleteTip": {
"en": "Are you sure to delete this email?",
@@ -1841,8 +1857,8 @@ export const MESSAGE_REGISTRY = {
"zh": "更改角色"
},
"createUser": {
"en": "Create User",
"zh": "创建用户"
"en": "Create User Account",
"zh": "创建用户账号"
},
"created_at": {
"en": "Created At",
@@ -1853,12 +1869,12 @@ export const MESSAGE_REGISTRY = {
"zh": "删除"
},
"deleteUser": {
"en": "Delete User",
"zh": "删除用户"
"en": "Delete User Account",
"zh": "删除用户账号"
},
"deleteUserTip": {
"en": "Are you sure you want to delete this user?",
"zh": "确定要删除此用户吗?"
"en": "Are you sure you want to delete this user account?",
"zh": "确定要删除此用户账号吗?"
},
"domains": {
"en": "Domains",
@@ -1905,12 +1921,12 @@ export const MESSAGE_REGISTRY = {
"zh": "成功"
},
"userAddressManagement": {
"en": "Address Management",
"zh": "地址管理"
"en": "Bound Address Management",
"zh": "绑定地址管理"
},
"user_email": {
"en": "User Email",
"zh": "用户邮箱"
"en": "User Account Email",
"zh": "用户账号邮箱"
}
},
"views.admin.Telegram": {
@@ -1973,7 +1989,7 @@ export const MESSAGE_REGISTRY = {
"zh": "邮箱地址凭证"
},
"addressCredentialTip": {
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
"en": "Copy this mailbox address credential to log in to this address.",
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
},
"addressPassword": {
@@ -2090,6 +2106,10 @@ export const MESSAGE_REGISTRY = {
}
},
"views.common.Appearance": {
"autoLoadRemoteImages": {
"en": "Automatically load external images in mail body",
"zh": "自动加载邮件正文中的外部图片"
},
"autoRefreshInterval": {
"en": "Auto Refresh Interval(Sec)",
"zh": "自动刷新间隔(秒)"
@@ -2107,8 +2127,20 @@ export const MESSAGE_REGISTRY = {
"zh": "左侧"
},
"mailboxSplitSize": {
"en": "Mailbox Split Size",
"zh": "邮箱界面分栏大小"
"en": "Left list width in two-column mailbox view",
"zh": "邮箱双栏视图左侧列表宽度占比"
},
"mailListView": {
"en": "Full-width mailbox list view",
"zh": "邮箱全宽列表视图"
},
"mailListPreviewLineClamp": {
"en": "Body Preview Lines",
"zh": "正文预览行数"
},
"off": {
"en": "Off",
"zh": "关闭"
},
"preferShowTextMail": {
"en": "Display text Mail by default",
@@ -2433,8 +2465,8 @@ export const MESSAGE_REGISTRY = {
"zh": "请输入天数"
},
"unboundAddressLabel": {
"en": "Cleanup the unbound address before n days",
"zh": "清理 n 天前的未绑定用户地址"
"en": "Clean up mailbox addresses unbound for n days",
"zh": "清理已解绑 n 天的邮箱地址"
}
},
"views.common.Login": {
@@ -2529,8 +2561,8 @@ export const MESSAGE_REGISTRY = {
},
"views.admin.Webhook": {
"enableAllowList": {
"en": "Enable Allow List (Restrict webhook access to specific users)",
"zh": "启用白名单 (限制 webhook 访问权限,只有白名单中的用户可以使用)"
"en": "Enable Allow List (Restrict webhook access to specific mailbox addresses)",
"zh": "启用白名单(仅允许白名单中的邮箱地址使用 Webhook)"
},
"manualInputPrompt": {
"en": "Type and press Enter to add",
+6
View File
@@ -88,6 +88,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 +99,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 +163,8 @@ export const useGlobalState = createGlobalState(
adminMailTabAddress,
adminSendBoxTabAddress,
mailboxSplitSize,
mailListView,
mailListPreviewLineClamp,
useIframeShowMail,
preferShowTextMail,
userJwt,
@@ -171,6 +176,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
View File
@@ -80,3 +80,4 @@ export function getDownloadEmlUrl(raw) {
new Blob([raw], { type: 'text/plain' }
))
}
+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 : '');
};
+4
View File
@@ -435,6 +435,10 @@ onMounted(async () => {
}
@media (max-width: 640px) {
:deep(.n-page-header) {
padding: 10px;
}
:deep(.n-page-header__title) {
min-width: 0;
}
+4 -1
View File
@@ -1,13 +1,16 @@
<script setup>
import { computed } from 'vue'
import { GithubAlt, Discord, Telegram } from '@vicons/fa'
import { useGlobalState } from '../../store'
import { sanitizeHtml } from '../../utils/sanitize-html'
const { announcement } = useGlobalState()
const safeAnnouncement = computed(() => sanitizeHtml(announcement.value))
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<div v-html="announcement"></div>
<div v-html="safeAnnouncement"></div>
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
<template #icon>
<n-icon :component="GithubAlt" />
+20 -3
View File
@@ -11,8 +11,8 @@ const props = defineProps({
})
const {
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
mailboxSplitSize, mailListView, mailListPreviewLineClamp, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteImages
} = useGlobalState()
const isMobile = useIsMobile()
@@ -23,12 +23,26 @@ const { t } = useScopedI18n('views.common.Appearance')
<div class="center">
<n-card :bordered="false" embedded>
<n-form-item-row v-if="!isMobile" :label="t('mailboxSplitSize')">
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
<n-slider v-model:value="mailboxSplitSize" :min="0" :max="0.75" :step="0.01" :marks="{
0: '0',
0.25: '0.25',
0.5: '0.5',
0.75: '0.75'
}" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('mailListView')">
<n-switch v-model:value="mailListView" :round="false" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('mailListPreviewLineClamp')">
<n-slider v-model:value="mailListPreviewLineClamp" :min="0" :max="5" :step="1" :marks="{
0: t('off'),
1: '1',
2: '2',
3: '3',
4: '4',
5: '5'
}" />
</n-form-item-row>
<n-form-item-row :label="t('autoRefreshInterval')">
<n-slider v-model:value="configAutoRefreshInterval" :min="30" :max="300" :step="1" :marks="{
60: '60', 120: '120', 180: '180', 240: '240'
@@ -46,6 +60,9 @@ const { t } = useScopedI18n('views.common.Appearance')
<n-form-item-row :label="t('useUTCDate')">
<n-switch v-model:value="useUTCDate" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('autoLoadRemoteImages')">
<n-switch v-model:value="autoLoadRemoteImages" :round="false" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
<n-switch v-model:value="useSideMargin" :round="false" />
</n-form-item-row>
+38 -5
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { ref, h, onMounted, watch } from 'vue';
import { useScopedI18n } from '@/i18n/app'
import { useRouter } from 'vue-router';
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
@@ -17,6 +17,9 @@ const router = useRouter()
const { locale, t } = useScopedI18n('views.user.AddressManagement')
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showTranferAddress = ref(false)
const currentAddress = ref("")
const currentAddressId = ref(0)
@@ -46,7 +49,11 @@ const unbindAddress = async (address_id) => {
body: JSON.stringify({ address_id })
});
message.success(t('unbindAddress') + " " + t('success'));
await fetchData();
if (page.value === 1) {
await fetchData();
} else {
page.value = 1;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
@@ -71,7 +78,11 @@ const transferAddress = async () => {
})
});
message.success(t('transferAddress') + " " + t('success'));
await fetchData();
if (page.value === 1) {
await fetchData();
} else {
page.value = 1;
}
showTranferAddress.value = false;
currentAddressId.value = 0;
currentAddress.value = "";
@@ -84,10 +95,17 @@ const transferAddress = async () => {
const fetchData = async () => {
try {
const { results } = await api.fetch(
`/user_api/bind_address`
const params = new URLSearchParams({
limit: String(pageSize.value),
offset: String((page.value - 1) * pageSize.value),
});
const { results, count: addressCount } = await api.fetch(
`/user_api/bind_address?${params.toString()}`
);
data.value = results;
if (page.value === 1) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
@@ -178,6 +196,10 @@ const columns = [
onMounted(async () => {
await fetchData()
})
watch([page, pageSize], async () => {
await fetchData();
})
</script>
<template>
@@ -197,6 +219,12 @@ onMounted(async () => {
<n-tabs type="segment">
<n-tab-pane name="address" :tab="t('address')">
<div class="address-table-scroll">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</n-tab-pane>
@@ -216,4 +244,9 @@ onMounted(async () => {
max-width: 100%;
overflow-x: auto;
}
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>
+1 -1
View File
@@ -32,7 +32,7 @@ const fetchMailData = async (limit, offset) => {
const fetchAddresData = async () => {
try {
const { results } = await api.fetch(
`/user_api/bind_address`
`/user_api/bind_address?limit=100&offset=0`
);
addressFilterOptions.value = results.map((item) => {
return {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "temp-email-pages",
"version": "1.9.0",
"version": "1.11.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.96.0"
"wrangler": "^4.119.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+17
View File
@@ -1,3 +1,5 @@
set -euo pipefail
cd frontend/
pnpm up
pnpm add -D wrangler@latest
@@ -17,3 +19,18 @@ cd vitepress-docs/
pnpm up --latest
pnpm add -D wrangler@latest
cd ..
cd e2e/ || exit 1
npx --yes npm-check-updates@23.0.0 --upgrade
npm install
PLAYWRIGHT_VERSION="$(node -p "require('./package.json').devDependencies['@playwright/test']")"
EXPECTED_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
if ! awk -v expected="${EXPECTED_IMAGE}" \
'$1 == "FROM" && $2 == expected { found=1 } END { exit !found }' \
Dockerfile.e2e; then
echo "Dockerfile.e2e must use Playwright ${PLAYWRIGHT_VERSION}" >&2
exit 1
fi
cd ..
+1
View File
@@ -7,3 +7,4 @@ imap_port=11143
# imap_tls_key=/path/to/key.pem
# imap_cache_size=500
# imap_http_timeout=30.0
# imap_flag_db_path=data/imap_flags.db
+1
View File
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
imap_tls_key: str = ""
imap_cache_size: int = 500
imap_http_timeout: float = 30.0
imap_flag_db_path: str = "data/imap_flags.db"
model_config = SettingsConfigDict(env_file=".env")
+3
View File
@@ -12,3 +12,6 @@ services:
- proxy_url=https://temp-email-api.xxx.xxx
- port=8025
- imap_port=11143
volumes:
# Persists IMAP flags (e.g. \Seen) across container restarts
- ./data:/app/data
+178
View File
@@ -0,0 +1,178 @@
import json
import os
import sqlite3
import threading
def _encode_flags(flags: set[str]) -> str:
# JSON, not a comma-joined string: commas are legal inside IMAP keywords,
# so "foo,bar" as a single keyword must not be split into two on read.
return json.dumps(sorted(flags))
def _decode_flags(text: str) -> set[str]:
if not text:
return set()
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return {str(flag) for flag in parsed}
except ValueError:
pass
# Rows written before the switch to JSON were comma-joined.
return set(text.split(","))
class FlagStore:
"""Persists IMAP message flags (e.g. \\Seen) to a local SQLite file.
SimpleMailbox previously kept flags only in an in-memory dict that was
recreated from scratch for every IMAP connection (see SimpleRealm.requestAvatar
in imap_server.py), so a client's STORE command (e.g. marking a message as
read) was silently lost as soon as the session ended. This store gives
flags a durable home keyed by (address, mailbox, uid) so they survive
reconnects.
"""
_UPSERT_SQL = """
INSERT INTO imap_flags (address, mailbox, uid, flags)
VALUES (?, ?, ?, ?)
ON CONFLICT(address, mailbox, uid) DO UPDATE SET flags = excluded.flags
"""
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
self._init_db()
def _connect(self) -> sqlite3.Connection:
# isolation_level=None: transactions are managed explicitly below, so
# sqlite3 must not inject its own implicit BEGIN.
return sqlite3.connect(self._db_path, timeout=10, isolation_level=None)
def _init_db(self):
dirname = os.path.dirname(self._db_path)
if dirname:
os.makedirs(dirname, exist_ok=True)
with self._lock:
conn = self._connect()
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS imap_flags (
address TEXT NOT NULL,
mailbox TEXT NOT NULL,
uid INTEGER NOT NULL,
flags TEXT NOT NULL,
PRIMARY KEY (address, mailbox, uid)
)
"""
)
finally:
conn.close()
def get_all(self, address: str, mailbox: str) -> dict[int, set[str]]:
"""Return {uid: flags} for every UID with stored flags in a mailbox."""
with self._lock:
conn = self._connect()
try:
rows = conn.execute(
"SELECT uid, flags FROM imap_flags WHERE address = ? AND mailbox = ?",
(address, mailbox),
).fetchall()
finally:
conn.close()
return {uid: _decode_flags(flags) for uid, flags in rows}
def update_flags(
self,
address: str,
mailbox: str,
uids: list[int],
flags: set[str],
mode: int,
) -> dict[int, set[str]]:
"""Atomically apply an IMAP STORE to the given UIDs.
mode follows twisted's convention: 1 adds (+FLAGS), -1 removes
(-FLAGS), 0 replaces (FLAGS). Returns {uid: resulting flags}.
The read-modify-write runs inside one BEGIN IMMEDIATE transaction:
two concurrent sessions that loaded the same stale in-memory flags
cannot overwrite each other's STORE, because each session's update is
recomputed from the row current at its own commit.
"""
if not uids:
return {}
flags = set(flags)
result: dict[int, set[str]] = {}
with self._lock:
conn = self._connect()
try:
conn.execute("BEGIN IMMEDIATE")
try:
placeholders = ",".join("?" for _ in uids)
rows = conn.execute(
"SELECT uid, flags FROM imap_flags"
" WHERE address = ? AND mailbox = ?"
f" AND uid IN ({placeholders})",
(address, mailbox, *uids),
).fetchall()
current = {uid: _decode_flags(text) for uid, text in rows}
upserts = []
for uid in uids:
old = current.get(uid, set())
if mode == 1:
new = old | flags
elif mode == -1:
new = old - flags
else:
new = set(flags)
result[uid] = new
upserts.append((address, mailbox, uid, _encode_flags(new)))
conn.executemany(self._UPSERT_SQL, upserts)
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
return result
def set_flags_bulk(
self, address: str, mailbox: str, uid_flags: dict[int, set[str]]
) -> None:
"""Upsert flags for multiple UIDs in a single transaction."""
if not uid_flags:
return
rows = [
(address, mailbox, uid, _encode_flags(flags))
for uid, flags in uid_flags.items()
]
with self._lock:
conn = self._connect()
try:
conn.execute("BEGIN IMMEDIATE")
try:
conn.executemany(self._UPSERT_SQL, rows)
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
_default_store: FlagStore | None = None
_default_store_lock = threading.Lock()
def get_flag_store() -> FlagStore:
"""Return the process-wide FlagStore, created lazily from settings."""
global _default_store
if _default_store is None:
with _default_store_lock:
if _default_store is None:
from config import settings
_default_store = FlagStore(settings.imap_flag_db_path)
return _default_store
+77 -31
View File
@@ -3,11 +3,12 @@ import logging
import time
from collections import OrderedDict
from twisted.internet import defer
from twisted.internet import defer, threads
from twisted.mail import imap4
from zope.interface import implementer
from config import settings
from flag_store import FlagStore, get_flag_store
from imap_http_client import BackendClient
from imap_message import SimpleMessage
from parse_email import generate_email_model, parse_email, clean_raw_headers, fix_mojibake
@@ -51,9 +52,12 @@ class MessageCache:
@implementer(imap4.IMailboxInfo, imap4.IMailbox, imap4.ISearchableMailbox)
class SimpleMailbox:
def __init__(self, name: str, client: BackendClient):
def __init__(self, name: str, client: BackendClient, address: str,
flag_store: FlagStore = None):
self.name = name
self._client = client
self._address = address
self._flag_store = flag_store if flag_store is not None else get_flag_store()
self.listeners = []
self.addListener = self.listeners.append
self.removeListener = self.listeners.remove
@@ -76,7 +80,10 @@ class SimpleMailbox:
return 0
def getUnseenCount(self):
return 0
return sum(
1 for u in self._uid_index
if r"\Seen" not in self._flags.get(u, set())
)
def isWriteable(self):
return 1
@@ -123,6 +130,7 @@ class SimpleMailbox:
if count == 0:
self._uid_index = []
self._uid_index_built = True
self._flags = {}
return
uid_set = set()
@@ -146,6 +154,11 @@ class SimpleMailbox:
self._uid_index = sorted(uid_set)
self._uid_index_built = True
# Load persisted flags (e.g. \Seen set by a previous IMAP session) so
# STORE results survive reconnects instead of resetting every session.
self._flags = yield threads.deferToThread(
self._flag_store.get_all, self._address, self.name
)
_logger.info(
"UID index built for %s: %d UIDs, range=%s..%s",
self.name, len(self._uid_index),
@@ -254,9 +267,10 @@ class SimpleMailbox:
else:
continue
if uid_val not in self._flags:
self._flags[uid_val] = {r"\Seen"}
flags = self._flags[uid_val]
# Flags default to unseen (empty set) unless a previous
# STORE persisted them via self._flag_store; self._flags
# is refreshed from that store in _build_uid_index().
flags = self._flags.get(uid_val, set())
msg = SimpleMessage(
uid_val, email_model, flags=flags, raw=raw,
created_at=item.get("created_at"),
@@ -309,18 +323,20 @@ class SimpleMailbox:
return {}
target_uids = self._resolve_message_set(messages, uid)
if not target_uids:
return {}
# Apply the delta inside the store's own transaction instead of
# computing new flag sets from this session's in-memory copy: another
# concurrent session may have STOREd since we loaded, and basing the
# write on stale state would silently drop its change.
updated_flags = yield threads.deferToThread(
self._flag_store.update_flags,
self._address, self.name, target_uids, set(flags), mode,
)
result = {}
for u in target_uids:
current_flags = self._flags.get(u, set())
if mode == 1: # +FLAGS
current_flags = current_flags | set(flags)
elif mode == -1: # -FLAGS
current_flags = current_flags - set(flags)
elif mode == 0: # FLAGS (replace)
current_flags = set(flags)
for u, current_flags in updated_flags.items():
self._flags[u] = current_flags
seq = self._uid_to_seq(u)
if seq is not None:
@@ -328,28 +344,58 @@ class SimpleMailbox:
return result
# SEARCH keys we can answer from persisted flag state, mapped to the flag
# and whether it must be present. Anything else falls through to matching
# every message, which is how this mailbox answered every query before
# flags were persisted.
_FLAG_SEARCH_KEYS = {
"SEEN": ("\\Seen", True),
"UNSEEN": ("\\Seen", False),
"DELETED": ("\\Deleted", True),
"UNDELETED": ("\\Deleted", False),
"FLAGGED": ("\\Flagged", True),
"UNFLAGGED": ("\\Flagged", False),
"ANSWERED": ("\\Answered", True),
"UNANSWERED": ("\\Answered", False),
"DRAFT": ("\\Draft", True),
"UNDRAFT": ("\\Draft", False),
}
@defer.inlineCallbacks
def search(self, query, uid):
if not self._uid_index_built:
yield self._build_uid_index()
results = []
predicates = []
for term in query:
if isinstance(term, str) and term.upper() == "ALL":
if uid:
results = list(self._uid_index)
else:
results = list(range(1, len(self._uid_index) + 1))
break
if isinstance(term, bytes):
term = term.decode("ascii", "ignore")
if not isinstance(term, str):
continue
predicate = self._FLAG_SEARCH_KEYS.get(term.upper())
if predicate is not None:
predicates.append(predicate)
if not results:
if uid:
results = list(self._uid_index)
else:
results = list(range(1, len(self._uid_index) + 1))
matched = [
u for u in self._uid_index
if all(
(flag in self._flags.get(u, set())) is present
for flag, present in predicates
)
]
return results
_logger.info(
"SEARCH: uid=%s predicates=%d matched=%d/%d",
uid, len(predicates), len(matched), len(self._uid_index),
)
if uid:
return matched
return [
seq for seq in (self._uid_to_seq(u) for u in matched)
if seq is not None
]
def getUIDNext(self):
if self._uid_index:
+46 -3
View File
@@ -48,6 +48,49 @@ class SimpleIMAPServer(imap4.IMAP4Server):
return real_write_seq(data)
self.transport.writeSequence = logging_write_seq
@staticmethod
def _fetch_implies_seen(query):
"""Whether a FETCH query marks the messages it returns as read.
Per RFC 3501, ``BODY[...]`` sets ``\\Seen`` while ``BODY.PEEK[...]``
does not. ``RFC822`` and ``RFC822.TEXT`` are defined as equivalents of
``BODY[]`` / ``BODY[TEXT]`` and so also set it; ``RFC822.HEADER`` is
equivalent to ``BODY.PEEK[HEADER]`` and does not.
"""
for part in query:
part_type = getattr(part, "type", None)
if part_type == "body" and not getattr(part, "peek", False):
return True
if part_type in ("rfc822", "rfc822text"):
return True
return False
def do_FETCH(self, tag, messages, query, uid=0):
"""Set ``\\Seen`` for non-peek fetches before delivering the response.
Twisted hands the parsed query to its own response builder and never
passes it to the mailbox, so the mailbox alone cannot tell a ``BODY[]``
from a ``BODY.PEEK[]``. Flag the messages here, before the FETCH
response is generated, so the flags the client receives are current.
"""
if not query or not self._fetch_implies_seen(query):
return imap4.IMAP4Server.do_FETCH(self, tag, messages, query, uid)
def _fetch(_):
return imap4.IMAP4Server.do_FETCH(self, tag, messages, query, uid)
def _store_failed(failure):
# A failure to persist \Seen must not cost the client its mail.
_logger.warning("FETCH: could not set \\Seen: %s", failure.value)
return None
d = defer.maybeDeferred(
self.mbox.store, messages, ["\\Seen"], 1, uid
)
d.addErrback(_store_failed)
d.addCallback(_fetch)
return d
def _cbSelectWork(self, mbox, cmdName, tag):
"""Override to add UIDNEXT in SELECT response (RFC 3501)."""
if mbox is None:
@@ -83,7 +126,7 @@ class Account(imap4.MemoryAccount):
def _emptyMailbox(self, name, id):
"""Return a dummy mailbox for CREATE requests (e.g. Gmail creating Drafts)."""
_logger.debug("Accepting CREATE request for %s", name)
return SimpleMailbox(name, self._client)
return SimpleMailbox(name, self._client, self.name)
def create(self, pathspec):
"""Accept CREATE silently without actually creating mailboxes."""
@@ -111,8 +154,8 @@ class SimpleRealm:
client = BackendClient(password)
inbox = SimpleMailbox("INBOX", client)
sent = SimpleMailbox("SENT", client)
inbox = SimpleMailbox("INBOX", client, username)
sent = SimpleMailbox("SENT", client, username)
account = Account(username)
account._client = client
+2 -2
View File
@@ -1,6 +1,6 @@
aiosmtpd==1.4.6
pydantic-settings==2.13.1
Twisted==25.5.0
pydantic-settings==2.14.2
Twisted==26.4.0
httpx==0.28.1
pyOpenSSL==26.0.0
service-identity==24.2.0
+288
View File
@@ -0,0 +1,288 @@
"""Regression test for GH issue #1074: IMAP STORE could not mark mail as read.
Root cause (see 1074-report.md for the full investigation):
- smtp_proxy_server/imap_server.py SimpleRealm.requestAvatar() builds a brand
new SimpleMailbox (with an empty in-memory `_flags` dict) on every IMAP
login/connection.
- smtp_proxy_server/imap_mailbox.py SimpleMailbox.store() only ever wrote
flag changes into that in-memory dict, and
SimpleMailbox._fetch_and_cache_messages() unconditionally defaulted every
message's flags to `{\\Seen}`.
- Net effect: every message always looked "already read", and a client's
STORE +FLAGS (\\Seen) command appeared to succeed but was silently
discarded the moment the IMAP session ended (e.g. Thunderbird reconnecting
to poll), which matches the reported "cannot mark mail as read".
This test drives SimpleMailbox directly (no Docker / no live worker
backend) against a FakeBackendClient, and verifies that:
1. A never-touched message starts unseen (not hardcoded \\Seen).
2. STORE +FLAGS (\\Seen) is visible to a brand new SimpleMailbox instance
pointed at the same flag_store.FlagStore, simulating a client
disconnect + reconnect.
"""
import os
import shutil
import tempfile
import unittest
from twisted.internet import defer
from twisted.mail import imap4
from twisted.python.failure import Failure
import imap_mailbox
from flag_store import FlagStore
from imap_mailbox import SimpleMailbox
from imap_server import SimpleIMAPServer
def _sync_defer_to_thread(f, *args, **kwargs):
"""Stand-in for twisted.internet.threads.deferToThread.
imap_mailbox.py offloads FlagStore's blocking sqlite calls to a thread
via deferToThread. Tests don't run a reactor to service that thread
pool, so this executes the callable inline and wraps the result as an
already-fired Deferred instead.
"""
try:
result = f(*args, **kwargs)
except Exception:
return defer.fail()
return defer.succeed(result)
def _run(d):
"""Extract the result of a Deferred that fires synchronously."""
box = []
d.addBoth(box.append)
assert box, "Deferred did not fire synchronously"
value = box[0]
if isinstance(value, Failure):
value.raiseException()
return value
class FakeBackendClient:
"""Minimal stand-in for imap_http_client.BackendClient.
Serves a fixed in-memory list of message rows so tests don't need a
live worker backend or Docker.
"""
def __init__(self, messages):
self._messages = messages
def get_message_count(self, mailbox_name):
return defer.succeed(len(self._messages))
def get_messages(self, mailbox_name, limit, offset):
page = self._messages[offset:offset + limit]
count = len(self._messages) if offset == 0 else None
return defer.succeed((page, count))
class _MailboxTestBase(unittest.TestCase):
"""Shared fixture: a temp-dir FlagStore and an inline deferToThread."""
def setUp(self):
self._tmpdir = tempfile.mkdtemp()
self._db_path = os.path.join(self._tmpdir, "flags.db")
self._orig_deferToThread = imap_mailbox.threads.deferToThread
imap_mailbox.threads.deferToThread = _sync_defer_to_thread
def tearDown(self):
imap_mailbox.threads.deferToThread = self._orig_deferToThread
shutil.rmtree(self._tmpdir, ignore_errors=True)
def _make_mailbox(self, messages, address="user@example.com"):
flag_store = FlagStore(self._db_path)
client = FakeBackendClient(messages)
return SimpleMailbox("INBOX", client, address, flag_store=flag_store)
class ImapMarkAsSeenPersistenceTest(_MailboxTestBase):
def test_new_message_starts_unseen(self):
mbox = self._make_mailbox([{"id": 1, "raw": ""}])
_run(mbox._build_uid_index())
self.assertEqual(mbox.getUnseenCount(), 1)
def test_store_seen_survives_reconnect(self):
messages = [{"id": 1, "raw": ""}, {"id": 2, "raw": ""}]
# --- Session 1: client connects, selects INBOX, sees 2 unseen ---
mbox1 = self._make_mailbox(messages)
_run(mbox1._build_uid_index())
self.assertEqual(mbox1.getUnseenCount(), 2)
# Client sends: UID STORE 1 +FLAGS (\Seen)
message_set = imap4.MessageSet(1, 1)
result = _run(mbox1.store(message_set, [r"\Seen"], mode=1, uid=True))
self.assertIn(1, [seq for seq in result])
self.assertEqual(mbox1.getUnseenCount(), 1)
# --- Session 2: client disconnects and reconnects (new SimpleMailbox
# instance, exactly like SimpleRealm.requestAvatar creates per login) ---
mbox2 = self._make_mailbox(messages)
_run(mbox2._build_uid_index())
self.assertEqual(
mbox2.getUnseenCount(), 1,
"flag set via STORE in a previous session was lost on reconnect",
)
self.assertIn(r"\Seen", mbox2._flags.get(1, set()))
self.assertNotIn(r"\Seen", mbox2._flags.get(2, set()))
def test_store_minus_flags_removes_seen(self):
messages = [{"id": 1, "raw": ""}]
mbox = self._make_mailbox(messages)
_run(mbox._build_uid_index())
message_set = imap4.MessageSet(1, 1)
_run(mbox.store(message_set, [r"\Seen"], mode=1, uid=True))
self.assertEqual(mbox.getUnseenCount(), 0)
_run(mbox.store(message_set, [r"\Seen"], mode=-1, uid=True))
self.assertEqual(mbox.getUnseenCount(), 1)
# The removal must also land in SQLite, not just this instance's
# in-memory dict: a fresh mailbox (= reconnect) must see it unseen.
mbox2 = self._make_mailbox(messages)
_run(mbox2._build_uid_index())
self.assertEqual(
mbox2.getUnseenCount(), 1,
"-FLAGS (\\Seen) was not persisted across reconnect",
)
self.assertNotIn(r"\Seen", mbox2._flags.get(1, set()))
def test_keyword_containing_comma_round_trips(self):
# Commas are legal in IMAP keywords; "foo,bar" is ONE keyword and
# must not come back from storage split into "foo" and "bar".
messages = [{"id": 1, "raw": ""}]
mbox = self._make_mailbox(messages)
_run(mbox._build_uid_index())
message_set = imap4.MessageSet(1, 1)
_run(mbox.store(message_set, ["foo,bar"], mode=1, uid=True))
mbox2 = self._make_mailbox(messages)
_run(mbox2._build_uid_index())
self.assertEqual(mbox2._flags.get(1), {"foo,bar"})
def test_concurrent_sessions_do_not_clobber_each_other(self):
# Two sessions load the same (empty) flags, then each STOREs a
# different flag on the same message. The second write must not be
# computed from its stale in-memory copy, or the first flag is lost.
messages = [{"id": 1, "raw": ""}]
mbox1 = self._make_mailbox(messages)
mbox2 = self._make_mailbox(messages)
_run(mbox1._build_uid_index())
_run(mbox2._build_uid_index())
message_set = imap4.MessageSet(1, 1)
_run(mbox1.store(message_set, [r"\Seen"], mode=1, uid=True))
_run(mbox2.store(message_set, [r"\Flagged"], mode=1, uid=True))
mbox3 = self._make_mailbox(messages)
_run(mbox3._build_uid_index())
self.assertEqual(
mbox3._flags.get(1), {r"\Seen", r"\Flagged"},
"a session's +FLAGS was lost to a concurrent session's stale write",
)
class ImapSearchFlagTest(_MailboxTestBase):
"""SEARCH must answer flag keys from persisted state.
SimpleMailbox declares ISearchableMailbox, so IMAP4Server.do_SEARCH hands
the whole query to it and never runs its own search_UNSEEN/search_SEEN
helpers. Before this, every SEARCH fell through to "return everything",
so `SEARCH UNSEEN` listed mail the user had already read.
"""
def _seen_mailbox(self):
"""Two messages, UID 1 marked \\Seen, UID 2 left unread."""
messages = [{"id": 1, "raw": ""}, {"id": 2, "raw": ""}]
mbox = self._make_mailbox(messages)
_run(mbox._build_uid_index())
_run(mbox.store(imap4.MessageSet(1, 1), [r"\Seen"], mode=1, uid=True))
return mbox
def test_search_unseen_excludes_seen_messages(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["UNSEEN"], uid=True)), [2])
def test_search_seen_returns_only_seen_messages(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["SEEN"], uid=True)), [1])
def test_search_unseen_returns_sequence_numbers_when_not_uid(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["UNSEEN"], uid=False)), [2])
def test_search_all_still_returns_everything(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["ALL"], uid=True)), [1, 2])
def test_search_accepts_bytes_terms(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search([b"UNSEEN"], uid=True)), [2])
def test_unsupported_search_key_still_matches_everything(self):
# Keys this mailbox cannot evaluate keep the previous lenient
# behaviour rather than silently returning an empty result.
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["SINCE"], uid=True)), [1, 2])
def test_combined_keys_are_conjunctive(self):
mbox = self._seen_mailbox()
_run(mbox.store(imap4.MessageSet(2, 2), [r"\Flagged"], mode=1, uid=True))
self.assertEqual(_run(mbox.search(["UNSEEN", "FLAGGED"], uid=True)), [2])
self.assertEqual(_run(mbox.search(["SEEN", "FLAGGED"], uid=True)), [])
class FetchImpliesSeenTest(unittest.TestCase):
"""Only non-peek body fetches may set \\Seen (RFC 3501 §6.4.5).
Twisted passes the parsed FETCH query to its own response builder and
never to the mailbox, so SimpleMailbox alone cannot distinguish BODY[]
from BODY.PEEK[]. SimpleIMAPServer.do_FETCH makes that call instead.
"""
def _implies_seen(self, command):
parser = imap4._FetchParser()
parser.parseString(command)
return SimpleIMAPServer._fetch_implies_seen(parser.result)
def test_body_sets_seen(self):
self.assertTrue(self._implies_seen(b"BODY[]"))
def test_body_section_sets_seen(self):
# BODY[HEADER] is a non-peek fetch and does set \Seen, unlike the
# RFC822.HEADER shorthand below.
self.assertTrue(self._implies_seen(b"BODY[HEADER]"))
def test_body_peek_does_not_set_seen(self):
self.assertFalse(self._implies_seen(b"BODY.PEEK[]"))
def test_rfc822_sets_seen(self):
self.assertTrue(self._implies_seen(b"RFC822"))
def test_rfc822_text_sets_seen(self):
self.assertTrue(self._implies_seen(b"RFC822.TEXT"))
def test_rfc822_header_does_not_set_seen(self):
# Defined as equivalent to BODY.PEEK[HEADER].
self.assertFalse(self._implies_seen(b"RFC822.HEADER"))
def test_metadata_only_fetch_does_not_set_seen(self):
self.assertFalse(self._implies_seen(b"FLAGS"))
self.assertFalse(self._implies_seen(b"UID"))
self.assertFalse(self._implies_seen(b"RFC822.SIZE"))
def test_mixed_query_sets_seen_if_any_part_does(self):
self.assertTrue(self._implies_seen(b"(FLAGS BODY[])"))
self.assertFalse(self._implies_seen(b"(FLAGS BODY.PEEK[])"))
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -141,9 +141,9 @@ ENABLE_AUTO_REPLY = false
# FRONTEND_URL = "https://xxxx.xxx"
# Enable check junk mail
# ENABLE_CHECK_JUNK_MAIL = false
# junk mail check list, if status exists and status is not pass, will be marked as junk mail
# JUNK_MAIL_CHECK_LIST = = ["spf", "dkim", "dmarc"]
# junk mail force check pass list, if no status or status is not pass, will be marked as junk mail
# junk mail check list: reject registered failure/error results; none and SPF/DKIM neutral are treated as absent
# JUNK_MAIL_CHECK_LIST = ["spf", "dkim", "dmarc"]
# junk mail force pass list: every configured method must explicitly return pass
# JUNK_MAIL_FORCE_PASS_LIST = ["spf", "dkim", "dmarc"]
# remove attachment if size exceed 2MB, mail maybe mising some information due to parsing
# REMOVE_EXCEED_SIZE_ATTACHMENT = true
@@ -48,6 +48,12 @@ No additional env var is required.
Register at `https://resend.com/domains` and add DNS records according to the instructions.
> [!WARNING] DNS record proxy status on Cloudflare
> Resend domain verification CNAME records **must be set to DNS-only** (gray cloud) in
> the Cloudflare DNS dashboard. Proxied (orange cloud) records will prevent Resend from
> completing verification, and a single failed attempt can take several hours before
> Retry becomes available. See [#515](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515).
Create an `api key` on the `API KEYS` page.
Then execute the following command to add `RESEND_TOKEN` to secrets:
@@ -146,10 +152,12 @@ wrangler secret put SMTP_CONFIG
## Send Balance Mechanism
Users need a send balance to send emails. The balance mechanism works as follows:
Send access and balance belong to a **mailbox address**, not to a user account. A user account only binds, syncs, and manages multiple mailbox addresses. Even after signing in to a user account, select a specific mailbox address and request send access for that address. Each address has its own enabled state and balance.
A mailbox address needs a send balance to send emails. The balance mechanism works as follows:
1. **Auto-initialize Default Quota**: When `DEFAULT_SEND_BALANCE > 0`, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can click "Request Access for This Address" on the current mailbox address's send page to create an address-specific request for admins to review
3. **Unlimited Sending**: The following methods can bypass balance checks:
- Add the address to the "No Limit Send Address List" in the admin console
- Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits
@@ -1,11 +1,11 @@
# Admin User Management
# Admin User Account Management
## User Management Page
## User Account Management Page
![admin-user-management](/feature/admin-user-management.png)
## User Settings
## User Account Settings
Configure user login and authentication settings here
Configure site user account login, registration, and authentication here. A user account binds and manages mailbox addresses; it is not itself a mailbox address.
![admin-user-page](/feature/admin-user-page.png)
+12 -6
View File
@@ -8,24 +8,30 @@ After deploying the frontend application, click the upper-left logo 5 times or v
You need to configure `ADMIN_PASSWORDS` in the backend or ensure the current user role is `ADMIN_USER_ROLE`, otherwise access to the console will be denied.
## Admin Passwords vs User Accounts
## Admin Passwords, User Accounts, and Mailbox Addresses
`ADMIN_PASSWORDS` is the management password for the Admin console. It is not a site user account
and does not correspond to any mailbox address. Logging in with an admin password grants access to
the console, but that login itself cannot receive mail.
Site user accounts are stored in the `users` table and use the user login flow. Whether a user can
receive mail depends on whether they created or bound a mailbox address. Creating a normal user
whose email looks like `admin@example.com` does not automatically grant admin permissions.
Site user accounts are stored in the `users` table and use the user login flow. They bind, sync, and
manage multiple mailbox addresses, but do not have an inbox, address credential, or send balance by
themselves.
Mailbox addresses are stored in the `address` table. Each address has its own inbox, address
credential, and send access. A user can receive mail only after creating or binding an address, and
send access must be requested for a specific mailbox address rather than for the user account.
Creating a normal user whose email looks like `admin@example.com` does not automatically grant admin
permissions.
If you want a user account to access the Admin console, configure `ADMIN_USER_ROLE` and assign the
same role to that user in user management.
![admin](/feature/admin.png)
## Account List Sorting
## Mailbox Address List Sorting
The Accounts tab in the admin console supports column sorting. Click the column header to toggle ascending/descending order for:
The Mailbox Addresses tab in the admin console supports column sorting. Click the column header to toggle ascending/descending order for:
- ID
- Name
@@ -93,6 +93,10 @@ curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
Requires `send_balance > 0` (check via `/api/settings`). The deployment must have a send method configured (Resend / SMTP / Cloudflare Email Routing binding).
::: tip Send access belongs to a mailbox address
`/api/request_send_mail_access` requests access for the mailbox address identified by the Address JWT in `Authorization`, not for a user account. Request access separately for each mailbox address; a User JWT cannot replace the Address JWT on this endpoint.
:::
| Task | Method | Path | Body / Returns |
| ----------------------- | ------ | ------------------------------- | ------------------------------------------- |
| Request send access | POST | `/api/request_send_mail_access` | `{}``{ status: "ok" }` |
@@ -64,6 +64,7 @@ services:
| `imap_tls_key` | empty | IMAP TLS private key file path (PEM) |
| `imap_cache_size` | `500` | Max cached messages per mailbox |
| `imap_http_timeout` | `30.0` | Backend HTTP request timeout (seconds) |
| `imap_flag_db_path` | `data/imap_flags.db` | SQLite file storing IMAP flags (e.g. `\Seen`) so `STORE` (mark as read) survives client reconnects. Mount a volume over its parent directory so it persists across container restarts |
## Enabling STARTTLS
@@ -49,6 +49,27 @@ print(response.json())
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
## Admin Get Mail API
Fetch a single mail by mail ID without a mailbox JWT. Authenticate with `x-admin-auth`.
The response matches one entry returned by `/admin/mails`: gzip-compressed raw content is decompressed into `raw`, and `raw_blob` is excluded.
```python
import requests
mail_id = 1
url = f"https://<your-worker-address>/admin/mails/{mail_id}"
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.get(url, headers=headers)
print(response.json())
```
## Admin Delete Mail API
Delete a single mail by mail ID.
@@ -139,6 +160,36 @@ This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/re
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
:::
### Bound Address List
`GET /user_api/bind_address` uses server-side pagination and accepts these query parameters:
Requests without pagination parameters return the default first page. Fetching all bound addresses in one request is not supported.
| Parameter | Default | Description |
| --- | --- | --- |
| `limit` | `20` | Page size, from 1 to 100 |
| `offset` | `0` | Pagination offset |
The `results` array contains only the current page. The total is queried only when `offset=0`; later pages return `count: 0`, so clients should retain the total from the first page.
```python
import requests
url = "https://<your-worker-address>/user_api/bind_address"
headers = {
"x-user-token": "<your-user-JWT-token>",
}
querystring = {
"limit": "20",
"offset": "0",
}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
### User Mail List
Supports `address` filter
```python
+3 -3
View File
@@ -1,7 +1,7 @@
# Star History
<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>
+5 -2
View File
@@ -97,14 +97,17 @@
| ------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `BLACK_LIST` | Text | Blacklist for filtering senders, comma separated | `gov.cn,edu.cn` |
| `ENABLE_CHECK_JUNK_MAIL` | Text/JSON | Whether to enable junk mail checking, used with the following two lists | `false` |
| `JUNK_MAIL_CHECK_LIST` | JSON | Junk mail check configuration, marked as junk if any item `exists` and `fails` | `["spf", "dkim", "dmarc"]` |
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | Junk mail check configuration, marked as junk if any item `does not exist` or `fails` | `["spf", "dkim", "dmarc"]` |
| `JUNK_MAIL_CHECK_LIST` | JSON | Existence check; registered failure/error results are junk, while `none` and SPF/DKIM `neutral` are treated as absent | `["spf", "dkim", "dmarc"]` |
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | Strict pass check; every item must explicitly return `pass`, otherwise it is treated as junk | `["spf", "dkim", "dmarc"]` |
| `FORWARD_ADDRESS_LIST` | JSON | Global forward address list, disabled if not configured, all emails will be forwarded to listed addresses when enabled | `["xxx@xxx.com"]` |
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
| `CLEANUP_BATCH_SIZE` | Number | Per-run limit for mail, sent-mail, and creation/activity-based address cleanup. Defaults to `3000`, valid range `1-5000`. Smaller values reduce per-run D1 pressure; larger values clear backlogs faster | `3000` |
> [!NOTE]
> Authentication results follow their standards: SPF `none` means no usable domain or SPF record was found, and SPF `neutral` must be treated like `none`; DKIM `none` means the message was unsigned, and DKIM `neutral` is also treated as unsigned; DMARC `none` means no applicable DMARC policy was found. Unregistered results and unsupported method versions are ignored. `JUNK_MAIL_CHECK_LIST` treats these results as absent, while `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
>
> `ENABLE_MAIL_GZIP` adds CPU cost for gzip compression on write and decompression on read. Free-tier Workers are more likely to hit CPU limits, so a paid plan is recommended before enabling it
>
> `Junk mail checking` and `attachment removal` require email parsing, free tier CPU is limited, may cause large email parsing timeout
@@ -48,6 +48,12 @@ send_email = [
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
> [!WARNING] Cloudflare 上 DNS 记录的代理状态
> Resend 域名验证的 CNAME 记录**必须设置为 仅 DNS**(灰云),在
> Cloudflare DNS 控制面板中代理(橙云)记录会阻止 Resend
> 完成验证,且一次失败的尝试可能需要数小时才能重试。
> 参见 [#515](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515)。
`API KEYS` 页面创建 `api key`
然后执行下面的命令,将 `RESEND_TOKEN` 添加到 secrets 中
@@ -146,10 +152,12 @@ wrangler secret put SMTP_CONFIG
## 发信余额机制
用户发送邮件需要有发信余额。余额机制如下:
发信权限和额度属于**邮箱地址**,不属于用户账号。用户账号只用于绑定、同步和管理多个邮箱地址;即使已登录用户账号,也需要先切换到具体邮箱地址,再为该地址申请发信权限。每个地址的启用状态和余额互相独立。
邮箱地址发送邮件需要有发信余额。余额机制如下:
1. **自动初始化默认额度**:当 `DEFAULT_SEND_BALANCE > 0` 时,用户打开前端发信页或第一次调用发信接口时,系统会自动为该地址初始化默认额度
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在前端界面点击「申请发信权限」按钮,创建待管理员处理的发信权限记录
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在当前邮箱地址的发信页面点击「为当前地址申请发信权限」,创建待管理员处理的地址发信权限记录
3. **无限制发送**:以下方式可以跳过余额检查:
- 在 admin 后台将地址加入「无限制发送地址列表」
- 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色
@@ -1,11 +1,11 @@
# Admin 用户相关
# Admin 用户账号管理
## 用户管理页面
## 用户账号管理页面
![admin-user-management](/feature/admin-user-management.png)
## 用户设置
## 用户账号设置
此处开启用户登录,以及验证等配置
此处配置站点用户账号的登录、注册和验证。用户账号用于绑定和管理邮箱地址,本身不是邮箱地址。
![admin-user-page](/feature/admin-user-page.png)
@@ -8,19 +8,21 @@
需要在后端配置 `ADMIN_PASSWORDS` 或者当前用户角色为 `ADMIN_USER_ROLE`,否则不允许访问控制台。
## 管理口令用户账号的区别
## 管理口令用户账号和邮箱地址的区别
`ADMIN_PASSWORDS` 是 Admin 控制台的管理口令,不是站点用户账号,也不对应某个邮箱地址。使用管理口令登录后可以进入后台,但它本身不能收信。
站点用户账号存储在 `users` 表中,需要通过用户登录体系进入;用户是否能收信取决于是否创建或绑定了邮箱地址。即使你创建了一个邮箱为 `admin@example.com` 或用户名看起来像 `admin` 的普通用户,它也不会自动获得后台权限
站点用户账号存储在 `users` 表中,需要通过用户登录体系进入。它用于绑定、同步和管理多个邮箱地址,本身没有收件箱、地址凭证或发信额度
邮箱地址存储在 `address` 表中,每个地址有独立的收件箱、地址凭证和发信权限。用户是否能收信取决于是否创建或绑定了邮箱地址;发信权限也必须为具体邮箱地址申请,而不是为用户账号申请。即使你创建了一个邮箱为 `admin@example.com` 或用户名看起来像 `admin` 的普通用户,它也不会自动获得后台权限。
如果希望某个用户也能进入 Admin 控制台,请配置 `ADMIN_USER_ROLE`,并在用户管理中给该用户设置相同的角色。
![admin](/feature/admin.png)
## 账号列表排序
## 邮箱地址列表排序
管理后台的账号标签页支持按列排序,可点击表头对以下列进行升序/降序排列:
管理后台的邮箱地址标签页支持按列排序,可点击表头对以下列进行升序/降序排列:
- ID
- 名称
@@ -93,6 +93,10 @@ curl -s "$BASE/api/parsed_mails?limit=20&offset=0" \
需要 `send_balance > 0`(通过 `/api/settings` 查看),且部署方已配置发送方式(Resend / SMTP / Cloudflare Email Routing binding)。
::: tip 发信权限属于邮箱地址
`/api/request_send_mail_access` 申请的是 `Authorization` 中 Address JWT 对应邮箱地址的发信权限,不是用户账号的权限。不同邮箱地址需要分别申请;User JWT 不能代替 Address JWT 调用该接口。
:::
| 任务 | 方法 | 路径 | 请求体 / 返回 |
| ---------------- | ------ | ------------------------------- | ------------------------------------------ |
| 申请发信权限 | POST | `/api/request_send_mail_access` | `{}``{ status: "ok" }` |
@@ -64,6 +64,7 @@ services:
| `imap_tls_key` | 空 | IMAP TLS 私钥文件路径(PEM |
| `imap_cache_size` | `500` | 每个邮箱的消息缓存上限 |
| `imap_http_timeout` | `30.0` | 后端 HTTP 请求超时时间(秒) |
| `imap_flag_db_path` | `data/imap_flags.db` | 存储 IMAP 标志(如 `\Seen`)的 SQLite 文件路径,使 `STORE`(标记已读)在客户端断线重连后依然生效。建议挂载卷到其所在目录以在容器重启后保留 |
## 启用 STARTTLS
@@ -49,6 +49,27 @@ print(response.json())
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
## admin 获取单封邮件 API
无需邮箱 JWT,通过邮件 ID 获取单封邮件,并使用 `x-admin-auth` 认证。
返回结构与 `/admin/mails` 中的单条记录一致:gzip 压缩的原始邮件会解压到 `raw`,响应不包含 `raw_blob`
```python
import requests
mail_id = 1
url = f"https://<你的worker地址>/admin/mails/{mail_id}"
headers = {
"x-admin-auth": "<你的Admin密码>",
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
}
response = requests.get(url, headers=headers)
print(response.json())
```
## admin 删除邮件 API
通过邮件 ID 删除单封邮件。
@@ -139,6 +160,36 @@ print(response.json())
- 用户 JWT 使用 `x-user-token: <jwt>` 访问 `/user_api/*` 接口
:::
### 用户绑定地址列表
`GET /user_api/bind_address` 使用服务端分页,支持以下查询参数:
未携带分页参数时返回默认第一页,不支持一次获取全部绑定地址。
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| `limit` | `20` | 每页数量,范围为 1100 |
| `offset` | `0` | 分页偏移量 |
响应中的 `results` 仅包含当前页。仅 `offset=0` 时查询总数,后续页面的 `count``0`,客户端应保留第一页返回的总数。
```python
import requests
url = "https://<你的worker地址>/user_api/bind_address"
headers = {
"x-user-token": "<你的用户JWT Token>",
}
querystring = {
"limit": "20",
"offset": "0",
}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
### 用户邮件列表
支持 `address` 过滤
```python

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