mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-27 11:10:17 +08:00
Compare commits
8 Commits
fix/clarif
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce937fc4a9 | ||
|
|
5162f0a478 | ||
|
|
e06b7ccbb2 | ||
|
|
24016a0051 | ||
|
|
acd9a8f099 | ||
|
|
148fb09754 | ||
|
|
5951998aad | ||
|
|
01aa97e384 |
@@ -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/, plus npm upgrades on e2e/.
|
||||
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/.
|
||||
---
|
||||
|
||||
# Upgrade Dependencies
|
||||
@@ -23,21 +23,18 @@ 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/` 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`.
|
||||
Note: `vitepress-docs/` uses `--latest` (crosses semver ranges); other packages upgrade within ranges only.
|
||||
|
||||
## Post-upgrade checklist
|
||||
|
||||
1. Inspect `git diff` on `package.json`, `pnpm-lock.yaml`, and `package-lock.json` files for reasonable changes.
|
||||
1. Inspect `git diff` on `package.json` / `pnpm-lock.yaml` 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`
|
||||
- `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`.
|
||||
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`.
|
||||
|
||||
## Do NOT
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -142,6 +142,3 @@ 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/
|
||||
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -6,56 +6,20 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.11.0(main)
|
||||
## v1.10.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
### 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)
|
||||
- feat: |Frontend| 外观设置新增“自动加载远程内容”开关,默认关闭;关闭后邮件 HTML 中的远程图片默认以占位图显示,并支持单封邮件临时加载远程内容(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
|
||||
|
||||
@@ -6,56 +6,20 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.11.0(main)
|
||||
## v1.10.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
### 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)
|
||||
- feat: |Frontend| Add an Appearance toggle for automatically loading remote content, disabled by default; when disabled, HTML mail shows blocked-image placeholders by default and supports temporarily loading remote content for a single mail (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
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
<a href="README_EN.md">English Document</a>
|
||||
</p>
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
@@ -75,9 +74,9 @@
|
||||
<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" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
<a href="README_EN.md">English Document</a>
|
||||
</p>
|
||||
|
||||
> This project is for learning and personal use only. Please do not use it for any illegal activities, or you will be responsible for the consequences.
|
||||
@@ -75,9 +74,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://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" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
202
README_JA.md
202
README_JA.md
@@ -1,202 +0,0 @@
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare 一時メール - 無料で構築できる一時メールサービス
|
||||
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
</p>
|
||||
|
||||
> 本プロジェクトは学習および個人利用のみを目的としています。違法行為には使用しないでください。使用した場合、その結果については利用者自身が責任を負うものとします。
|
||||
|
||||
**機能の充実した一時メールサービスです!**
|
||||
|
||||
- **完全無料** - Cloudflare の無料サービス上に構築され、運用コストはかかりません
|
||||
- **高性能** - Rust WASM によるメール解析で極めて高速に応答します
|
||||
- **モダンな UI** - 多言語対応のレスポンシブデザインで、簡単に操作できます
|
||||
- **アドレスパスワード** - メールアドレスごとに個別のパスワードを設定し、セキュリティを強化できます
|
||||
- **Agent フレンドリー** - AI agent がメールボックスを利用できる組み込みの [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) を提供します
|
||||
- **モバイル管理** - Android の管理画面とメールボックス管理に対応したコミュニティクライアント [CloudMail](https://github.com/Lur1N77777/CloudMail) を利用できます
|
||||
|
||||
## デプロイドキュメント - クイックスタート
|
||||
|
||||
[ドキュメント](https://temp-mail-docs.awsl.uk) | [GitHub Actions デプロイガイド](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
|
||||
|
||||
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## 変更履歴
|
||||
|
||||
最新の更新内容については [CHANGELOG](CHANGELOG.md) を参照してください。
|
||||
|
||||
## ライブデモ
|
||||
|
||||
今すぐ試す → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>サービス稼働状況(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Star History(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>目次(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
- [Cloudflare 一時メール - 無料で構築できる一時メールサービス](#cloudflare-一時メール---無料で構築できる一時メールサービス)
|
||||
- [デプロイドキュメント - クイックスタート](#デプロイドキュメント---クイックスタート)
|
||||
- [変更履歴](#変更履歴)
|
||||
- [ライブデモ](#ライブデモ)
|
||||
- [主な機能](#主な機能)
|
||||
- [メール処理](#メール処理)
|
||||
- [ユーザー管理](#ユーザー管理)
|
||||
- [管理機能](#管理機能)
|
||||
- [多言語対応とインターフェース](#多言語対応とインターフェース)
|
||||
- [連携と拡張](#連携と拡張)
|
||||
- [技術アーキテクチャ](#技術アーキテクチャ)
|
||||
- [システム構成](#システム構成)
|
||||
- [技術スタック](#技術スタック)
|
||||
- [主要コンポーネント](#主要コンポーネント)
|
||||
- [コミュニティに参加](#コミュニティに参加)
|
||||
|
||||
</details>
|
||||
|
||||
## 主な機能
|
||||
|
||||
<details open>
|
||||
<summary>主な機能の詳細(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
### メール処理
|
||||
|
||||
- [x] `rust wasm` でメールを高速に解析します。ほぼすべてのメールを解析でき、Node.js の解析モジュールで失敗するメールも rust wasm なら正常に解析できます
|
||||
- [x] **AI メール認識** - Cloudflare Workers AI を使用して、メール内の確認コード、認証リンク、サービスリンクなどの重要情報を自動抽出します
|
||||
- [x] 指定したベースドメインに対して、ランダムな第 2 レベルサブドメインのメールボックスを任意で作成できます
|
||||
- [x] `DKIM` 検証付きのメール送信に対応します
|
||||
- [x] `SMTP` や `Resend` など、複数の送信方法に対応します
|
||||
- [x] 添付ファイルの表示機能を備え、添付画像も表示できます
|
||||
- [x] S3 での添付ファイルの保存と削除に対応します
|
||||
- [x] スパム検出とブラックリスト/ホワイトリストの設定に対応します
|
||||
- [x] グローバル転送先アドレスを指定できるメール転送機能を備えています
|
||||
|
||||
### ユーザー管理
|
||||
|
||||
- [x] `credentials` を使用して、以前利用したメールボックスへ再ログインできます
|
||||
- [x] 完全なユーザー登録・ログイン機能を備えています。メールアドレスを紐付けると、そのアドレスのメール JWT 資格情報を自動取得し、複数のメールボックスを切り替えられます
|
||||
- [x] `OAuth2` によるサードパーティログイン(Github、Authentik など)に対応します
|
||||
- [x] `Passkey` によるパスワードレスログインに対応します
|
||||
- [x] 複数ロールのドメインおよびプレフィックスを設定できるユーザーロール管理に対応します
|
||||
- [x] アドレスやキーワードで絞り込めるユーザー受信箱を提供します
|
||||
|
||||
### 管理機能
|
||||
|
||||
- [x] 完全な admin コンソールを備えています
|
||||
- [x] `admin` バックエンドからプレフィックスのないメールボックスを作成できます
|
||||
- [x] admin ユーザー管理画面でユーザーのアドレスを確認できます
|
||||
- [x] 複数のクリーンアップ戦略を選べる定期クリーンアップ機能を備えています
|
||||
- [x] カスタム名のメールボックスを取得でき、`admin` でブラックリストを設定できます
|
||||
- [x] アクセスパスワードを追加し、プライベートサイトとして利用できます
|
||||
|
||||
### 多言語対応とインターフェース
|
||||
|
||||
- [x] フロントエンドとバックエンドの両方が多言語に対応しています
|
||||
- [x] レスポンシブレイアウトのモダンな UI デザインを採用しています
|
||||
- [x] Google Ads の連携に対応します
|
||||
- [x] shadow DOM を使用してスタイルの干渉を防ぎます
|
||||
- [x] URL の JWT パラメーターによる自動ログインに対応します
|
||||
|
||||
### 連携と拡張
|
||||
|
||||
- [x] 完全な `Telegram Bot`、`Telegram` プッシュ通知、Telegram Bot ミニアプリに対応します
|
||||
- [x] `SMTP proxy server` を追加し、`SMTP` によるメール送信と `IMAP` によるメール閲覧に対応します
|
||||
- [x] Webhook とメッセージプッシュ連携に対応します
|
||||
- [x] `CF Turnstile` CAPTCHA 検証に対応します
|
||||
- [x] 悪用を防ぐためのレート制限を設定できます
|
||||
- [x] **Agent フレンドリー**:組み込みの [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill により、AI agent がメールボックスを直接利用できます。詳しくは[ドキュメント](vitepress-docs/docs/en/guide/feature/agent-email.md)を参照してください
|
||||
- [x] コミュニティのモバイル管理クライアント:[CloudMail](https://github.com/Lur1N77777/CloudMail) は本プロジェクト互換の API 向けに Expo / React Native で構築されており、Android 管理コンソール、アドレス管理、受信済み/送信済み/不明メール、確認コードのクイックコピー、OLED ブラックテーマ、ローカルグループ分けを提供します
|
||||
|
||||
</details>
|
||||
|
||||
## 技術アーキテクチャ
|
||||
|
||||
<details>
|
||||
<summary>技術アーキテクチャの詳細(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
### システム構成
|
||||
|
||||
- **データベース**:メインデータベースとして Cloudflare D1 を使用
|
||||
- **フロントエンドのデプロイ**:Cloudflare Pages を使用してフロントエンドをデプロイ
|
||||
- **バックエンドのデプロイ**:Cloudflare Workers を使用してバックエンドをデプロイ
|
||||
- **メールルーティング**:Cloudflare Email Routing を使用
|
||||
|
||||
### 技術スタック
|
||||
|
||||
- **フロントエンド**:Vue 3 + Vite + TypeScript
|
||||
- **バックエンド**:TypeScript + Cloudflare Workers
|
||||
- **メール解析**:Rust WASM(mail-parser-wasm)
|
||||
- **データベース**:Cloudflare D1(SQLite)
|
||||
- **ストレージ**:Cloudflare KV + R2(任意で S3)
|
||||
- **プロキシサービス**:Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 主要コンポーネント
|
||||
|
||||
- **Worker**:中核となるバックエンドサービス
|
||||
- **Frontend**:Vue 3 ユーザーインターフェース
|
||||
- **Mail Parser WASM**:Rust メール解析モジュール
|
||||
- **SMTP Proxy Server**:Python メールプロキシサービス
|
||||
- **Pages Functions**:Cloudflare Pages ミドルウェア
|
||||
- **Documentation**:VitePress ドキュメントサイト
|
||||
|
||||
</details>
|
||||
|
||||
### 重要な注意事項
|
||||
|
||||
- Resend でドメインレコードを追加する際、DNS プロバイダーが第 3 レベルドメイン a.b.com をホストしている場合は、Resend が生成したデフォルト名から第 2 レベルドメインのプレフィックス b を削除してください。削除しないと a.b.b.com が追加され、検証に失敗します。レコードを追加した後、次のコマンドで確認できます。
|
||||
```bash
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
|
||||
## コミュニティに参加
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
@@ -1,5 +1,5 @@
|
||||
# Keep this version in sync with @playwright/test in package.json
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
|
||||
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ 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}}
|
||||
"""
|
||||
|
||||
133
e2e/package-lock.json
generated
133
e2e/package-lock.json
generated
@@ -6,14 +6,14 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.6.5",
|
||||
"nodemailer": "^9.0.4"
|
||||
"imapflow": "^1.4.2",
|
||||
"nodemailer": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.2"
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -23,35 +23,35 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -69,13 +69,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
|
||||
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
|
||||
"version": "5.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz",
|
||||
"integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.1",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -113,9 +113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -129,25 +129,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.6.5.tgz",
|
||||
"integrity": "sha512-5XgLcfl6blDju2SP7TgTv4WIlTt7zxXYcfjznJFmfFrogHR2XzHGlS9s2wdqxv9ZJ5CPaRnig7KaK7qNoJPmlg==",
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
|
||||
"integrity": "sha512-73CGfb5+W0FkZ5CY4GfSdsoXyQ+17wdKkpMN2vwJHdLtOOFQWxv0ilG7KYY79XHBYg5njjqxXYB2FPw5Tl81zQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.14",
|
||||
"@zone-eu/mailsplit": "5.4.12",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.1",
|
||||
"libmime": "5.3.8",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "9.0.1",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.9"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -160,13 +161,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
|
||||
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
|
||||
"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.3",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
@@ -178,9 +179,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz",
|
||||
"integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==",
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -233,41 +234,41 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"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==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -353,34 +354,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^1.0.0"
|
||||
"real-require": "^0.2.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": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
|
||||
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"test:down": "docker compose down -v"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.2"
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.6.5",
|
||||
"nodemailer": "^9.0.4"
|
||||
"imapflow": "^1.4.2",
|
||||
"nodemailer": "^9.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,34 +140,6 @@ 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 {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
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');
|
||||
@@ -55,46 +53,3 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 Account Settings" tab ===
|
||||
await page.getByText('User Account Settings').click();
|
||||
// === Step 2: Click "User Settings" tab ===
|
||||
await page.getByText('User Settings').click();
|
||||
|
||||
// === Step 3: Create a passkey ===
|
||||
await page.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.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.17",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@unhead/vue": "^2.1.15",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.19.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"axios": "^1.18.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.5",
|
||||
"postal-mime": "^2.7.4",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.41",
|
||||
"vue": "^3.5.38",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-i18n": "^11.4.6",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"jsdom": "^28.1.0",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.6",
|
||||
"vite": "^7.3.5",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^4.1.10",
|
||||
"vitest": "^4.1.9",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1",
|
||||
"wrangler": "^4.119.0"
|
||||
"wrangler": "^4.104.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
2010
frontend/pnpm-lock.yaml
generated
2010
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ 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 {
|
||||
@@ -124,7 +123,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
notification.info({
|
||||
content: () => {
|
||||
return h("div", {
|
||||
innerHTML: sanitizeHtml(announcement.value)
|
||||
innerHTML: announcement.value
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ const buildLocalOptions = (excludeAddresses = new Set()) => {
|
||||
const buildUserOptions = async () => {
|
||||
const children = [];
|
||||
try {
|
||||
const { results } = await api.fetch(`/user_api/bind_address?limit=100&offset=0`);
|
||||
const { results } = await api.fetch(`/user_api/bind_address`);
|
||||
for (const row of results || []) {
|
||||
const address = row.address || row.name;
|
||||
if (!address) continue;
|
||||
|
||||
@@ -60,7 +60,7 @@ const props = defineProps({
|
||||
const localFilterKeyword = ref('')
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
|
||||
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
|
||||
autoRefresh, configAutoRefreshInterval, sendMailModel
|
||||
} = useGlobalState()
|
||||
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
|
||||
@@ -71,12 +71,6 @@ 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() === '') {
|
||||
@@ -189,7 +183,7 @@ const refresh = async () => {
|
||||
count.value = totalCount;
|
||||
}
|
||||
curMail.value = null;
|
||||
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
||||
if (!isMobile.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -208,11 +202,6 @@ 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;
|
||||
@@ -386,13 +375,8 @@ onBeforeUnmount(() => {
|
||||
clearable />
|
||||
</n-space>
|
||||
</div>
|
||||
<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>
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
@@ -428,25 +412,15 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template #2>
|
||||
<div v-if="curMail" style="margin: 8px;">
|
||||
<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-flex justify="space-between">
|
||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevMail') }}
|
||||
</n-button>
|
||||
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
@@ -472,48 +446,6 @@ 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;">
|
||||
@@ -623,80 +555,8 @@ 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,18 +1,46 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound, ImageRound } from '@vicons/material'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
|
||||
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { blockRemoteContent } from '../utils/remote-content-policy';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteContent } = useGlobalState();
|
||||
|
||||
const { t } = useScopedI18n('components.MailContentRenderer')
|
||||
|
||||
const blockedRemoteContentPlaceholder = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="80" viewBox="0 0 320 80">
|
||||
<rect width="320" height="80" rx="8" fill="#f3f4f6"/>
|
||||
<text x="160" y="45" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#6b7280">Remote content blocked</text>
|
||||
</svg>
|
||||
`)}`;
|
||||
|
||||
const hasRemoteUrl = (value: string | null) => /(?:^|,)\s*(?:https?:)?\/\//i.test(value || '');
|
||||
|
||||
const blockRemoteImages = (html?: string | null) => {
|
||||
if (!html || typeof DOMParser !== 'function') return html || '';
|
||||
|
||||
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(html);
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
for (const image of doc.querySelectorAll('img')) {
|
||||
const src = image.getAttribute('src');
|
||||
const srcset = image.getAttribute('srcset');
|
||||
if (!hasRemoteUrl(src) && !hasRemoteUrl(srcset)) continue;
|
||||
|
||||
if (src) image.setAttribute('data-blocked-src', src);
|
||||
if (srcset) image.setAttribute('data-blocked-srcset', srcset);
|
||||
image.removeAttribute('srcset');
|
||||
image.setAttribute('src', blockedRemoteContentPlaceholder);
|
||||
}
|
||||
|
||||
return hasDocumentShell ? `<!doctype html>\n${doc.documentElement.outerHTML}` : doc.body.innerHTML;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
mail: {
|
||||
type: Object,
|
||||
@@ -58,27 +86,16 @@ const showAttachments = ref(false);
|
||||
const curAttachments = ref([]);
|
||||
const attachmentLoding = ref(false);
|
||||
const showFullscreen = ref(false);
|
||||
const loadRemoteContentForCurrentMail = ref(false);
|
||||
const shouldLoadRemoteContent = computed(() => autoLoadRemoteContent.value || loadRemoteContentForCurrentMail.value);
|
||||
const mailHtmlContent = computed(() => shouldLoadRemoteContent.value
|
||||
? (props.mail.message || '')
|
||||
: blockRemoteImages(props.mail.message));
|
||||
|
||||
// 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;
|
||||
loadRemoteContentForCurrentMail.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();
|
||||
};
|
||||
@@ -171,21 +188,12 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
{{ t('fullscreen') }}
|
||||
</n-button>
|
||||
|
||||
</n-space>
|
||||
<n-button v-if="!showTextMail && !shouldLoadRemoteContent" size="small" tertiary type="info"
|
||||
@click="loadRemoteContentForCurrentMail = true">
|
||||
{{ t('loadRemoteContent') }}
|
||||
</n-button>
|
||||
|
||||
<!-- 外部资源阻断提示 -->
|
||||
<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>
|
||||
</n-space>
|
||||
|
||||
<!-- AI 提取信息 -->
|
||||
<AiExtractInfo :metadata="mail.metadata" />
|
||||
@@ -193,9 +201,9 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
<!-- 邮件内容 -->
|
||||
<div class="mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mailHtmlContent" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mailHtmlContent" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,9 +212,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="processedMail.message" class="mail-iframe">
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mailHtmlContent" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mailHtmlContent" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
@@ -251,11 +259,6 @@ 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;
|
||||
|
||||
@@ -210,13 +210,8 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<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>
|
||||
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
@@ -384,23 +379,4 @@ 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>
|
||||
|
||||
@@ -74,7 +74,6 @@ 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",
|
||||
@@ -179,6 +178,7 @@ export const deMessages = {
|
||||
"views.admin.SenderAccess.disable": "Deaktivieren",
|
||||
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
|
||||
"views.common.Appearance.preferShowTextMail": "Text-Mail standardmäßig anzeigen",
|
||||
"views.common.Appearance.autoLoadRemoteContent": "Remote-Inhalte automatisch laden",
|
||||
"views.admin.AccountSettings.domain_list": "Domain-Liste (optional)",
|
||||
"views.admin.AccountSettings.source_patterns_tip": "Die Domain-Liste filtert nach Empfängeradresse, die Quell-Regex nach Absenderadresse. Für die Weiterleitung müssen beide Bedingungen erfüllt sein (UND-Logik). Leer lassen, um den jeweiligen Filter zu überspringen.",
|
||||
"views.admin.UserManagement.domains": "Domains",
|
||||
@@ -238,6 +238,7 @@ export const deMessages = {
|
||||
"views.admin.AccountSettings.forward_address_required": "Eine Weiterleitungsadresse ist erforderlich",
|
||||
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
|
||||
"components.MailContentRenderer.fullscreen": "Vollbild",
|
||||
"components.MailContentRenderer.loadRemoteContent": "Remote-Inhalte laden",
|
||||
"views.common.Login.generateName": "Zufälligen Namen erzeugen",
|
||||
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
|
||||
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
|
||||
@@ -300,10 +301,7 @@ 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": "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.common.Appearance.mailboxSplitSize": "Größe der Mailbox-Aufteilung",
|
||||
"views.index.SimpleIndex.refreshSuccess": "E-Mails erfolgreich aktualisiert",
|
||||
"views.Admin.unknow": "E-Mails mit unbekanntem Empfänger",
|
||||
"views.Admin.maintenance": "Wartung",
|
||||
|
||||
@@ -74,7 +74,6 @@ 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",
|
||||
@@ -179,6 +178,7 @@ export const esMessages = {
|
||||
"views.admin.SenderAccess.disable": "Deshabilitar",
|
||||
"views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada",
|
||||
"views.common.Appearance.preferShowTextMail": "Mostrar correo en texto por defecto",
|
||||
"views.common.Appearance.autoLoadRemoteContent": "Cargar contenido remoto automáticamente",
|
||||
"views.admin.AccountSettings.domain_list": "Lista de dominios (opcional)",
|
||||
"views.admin.AccountSettings.source_patterns_tip": "La lista de dominios filtra por destinatario y la regex de origen por remitente. Ambas condiciones deben cumplirse para reenviar (lógica AND). Deja alguna vacía para omitirla.",
|
||||
"views.admin.UserManagement.domains": "Dominios",
|
||||
@@ -238,6 +238,7 @@ export const esMessages = {
|
||||
"views.admin.AccountSettings.forward_address_required": "La dirección de reenvío es obligatoria",
|
||||
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
|
||||
"components.MailContentRenderer.fullscreen": "Pantalla completa",
|
||||
"components.MailContentRenderer.loadRemoteContent": "Cargar contenido remoto",
|
||||
"views.common.Login.generateName": "Generar nombre aleatorio",
|
||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
|
||||
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
|
||||
@@ -300,10 +301,7 @@ 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": "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.common.Appearance.mailboxSplitSize": "Tamaño de división del buzón",
|
||||
"views.index.SimpleIndex.refreshSuccess": "Correos actualizados correctamente",
|
||||
"views.Admin.unknow": "Correos con destinatario desconocido",
|
||||
"views.Admin.maintenance": "Mantenimiento",
|
||||
|
||||
@@ -74,7 +74,6 @@ 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": "自動返信",
|
||||
@@ -179,6 +178,7 @@ export const jaMessages = {
|
||||
"views.admin.SenderAccess.disable": "無効化",
|
||||
"views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化",
|
||||
"views.common.Appearance.preferShowTextMail": "既定でテキストメールを表示",
|
||||
"views.common.Appearance.autoLoadRemoteContent": "リモートコンテンツを自動で読み込む",
|
||||
"views.admin.AccountSettings.domain_list": "ドメイン一覧(任意)",
|
||||
"views.admin.AccountSettings.source_patterns_tip": "ドメイン一覧は受信先アドレスで、送信元正規表現は送信者アドレスでフィルタします。転送には両方の条件を満たす必要があります(AND)。どちらかを空欄にするとその条件は無視されます。",
|
||||
"views.admin.UserManagement.domains": "ドメイン",
|
||||
@@ -238,6 +238,7 @@ export const jaMessages = {
|
||||
"views.admin.AccountSettings.forward_address_required": "転送先アドレスは必須です",
|
||||
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
|
||||
"components.MailContentRenderer.fullscreen": "全画面",
|
||||
"components.MailContentRenderer.loadRemoteContent": "リモートコンテンツを読み込む",
|
||||
"views.common.Login.generateName": "ランダム名を生成",
|
||||
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
|
||||
"views.common.Appearance.globalTabplacement": "全体タブ位置",
|
||||
@@ -300,10 +301,7 @@ export const jaMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "メールを削除しました",
|
||||
"views.user.UserLogin.cannotForgotPassword": "メール検証または登録が無効のため、パスワードを再設定できません。管理者へ連絡してください。",
|
||||
"views.Admin.mailWebhook": "メールWebhook",
|
||||
"views.common.Appearance.mailboxSplitSize": "メールボックス2カラム表示の左側リスト幅",
|
||||
"views.common.Appearance.mailListView": "メールボックス全幅リスト表示",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "本文プレビュー行数",
|
||||
"views.common.Appearance.off": "オフ",
|
||||
"views.common.Appearance.mailboxSplitSize": "メールボックス分割サイズ",
|
||||
"views.index.SimpleIndex.refreshSuccess": "メールを更新しました",
|
||||
"views.Admin.unknow": "受信者不明のメール",
|
||||
"views.Admin.maintenance": "メンテナンス",
|
||||
|
||||
@@ -74,7 +74,6 @@ 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",
|
||||
@@ -179,6 +178,7 @@ export const ptBRMessages = {
|
||||
"views.admin.SenderAccess.disable": "Desativar",
|
||||
"views.Admin.loginViaDisabledCheck": "Verificação de senha desativada",
|
||||
"views.common.Appearance.preferShowTextMail": "Exibir e-mail em texto por padrão",
|
||||
"views.common.Appearance.autoLoadRemoteContent": "Carregar conteúdo remoto automaticamente",
|
||||
"views.admin.AccountSettings.domain_list": "Lista de domínios (opcional)",
|
||||
"views.admin.AccountSettings.source_patterns_tip": "A lista de domínios filtra pelo destinatário e o regex de origem filtra pelo remetente. Ambas as condições precisam corresponder para encaminhar (lógica AND). Deixe qualquer uma vazia para ignorá-la.",
|
||||
"views.admin.UserManagement.domains": "Domínios",
|
||||
@@ -238,6 +238,7 @@ export const ptBRMessages = {
|
||||
"views.admin.AccountSettings.forward_address_required": "O endereço de encaminhamento é obrigatório",
|
||||
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
|
||||
"components.MailContentRenderer.fullscreen": "Tela cheia",
|
||||
"components.MailContentRenderer.loadRemoteContent": "Carregar conteúdo remoto",
|
||||
"views.common.Login.generateName": "Gerar nome aleatório",
|
||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
|
||||
"views.common.Appearance.globalTabplacement": "Posição global das abas",
|
||||
@@ -300,10 +301,7 @@ 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": "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.common.Appearance.mailboxSplitSize": "Tamanho da divisão da caixa de correio",
|
||||
"views.index.SimpleIndex.refreshSuccess": "E-mails atualizados com sucesso",
|
||||
"views.Admin.unknow": "E-mails com destinatário desconhecido",
|
||||
"views.Admin.maintenance": "Manutenção",
|
||||
|
||||
@@ -42,10 +42,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Auto Refresh",
|
||||
"zh": "自动刷新"
|
||||
},
|
||||
"backToList": {
|
||||
"en": "Back to List",
|
||||
"zh": "返回列表"
|
||||
},
|
||||
"cancelMultiAction": {
|
||||
"en": "Cancel Multi Action",
|
||||
"zh": "取消多选"
|
||||
@@ -190,13 +186,9 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Fullscreen",
|
||||
"zh": "全屏"
|
||||
},
|
||||
"loadRemoteImages": {
|
||||
"en": "Load Images",
|
||||
"zh": "加载图片"
|
||||
},
|
||||
"remoteImagesBlocked": {
|
||||
"en": "{count} remote resources blocked to protect your privacy",
|
||||
"zh": "已阻止 {count} 项外部资源以保护隐私"
|
||||
"loadRemoteContent": {
|
||||
"en": "Load Remote Content",
|
||||
"zh": "加载远程内容"
|
||||
},
|
||||
"reply": {
|
||||
"en": "Reply",
|
||||
@@ -852,10 +844,6 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Mail Count",
|
||||
"zh": "邮件数量"
|
||||
},
|
||||
"itemCount": {
|
||||
"en": "Total",
|
||||
"zh": "总数"
|
||||
},
|
||||
"name": {
|
||||
"en": "Name",
|
||||
"zh": "名称"
|
||||
@@ -2106,10 +2094,6 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.common.Appearance": {
|
||||
"autoLoadRemoteImages": {
|
||||
"en": "Automatically load external images in mail body",
|
||||
"zh": "自动加载邮件正文中的外部图片"
|
||||
},
|
||||
"autoRefreshInterval": {
|
||||
"en": "Auto Refresh Interval(Sec)",
|
||||
"zh": "自动刷新间隔(秒)"
|
||||
@@ -2127,25 +2111,17 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "左侧"
|
||||
},
|
||||
"mailboxSplitSize": {
|
||||
"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": "关闭"
|
||||
"en": "Mailbox Split Size",
|
||||
"zh": "邮箱界面分栏大小"
|
||||
},
|
||||
"preferShowTextMail": {
|
||||
"en": "Display text Mail by default",
|
||||
"zh": "默认以文本显示邮件"
|
||||
},
|
||||
"autoLoadRemoteContent": {
|
||||
"en": "Automatically load remote content",
|
||||
"zh": "自动加载远程内容"
|
||||
},
|
||||
"right": {
|
||||
"en": "right",
|
||||
"zh": "右侧"
|
||||
|
||||
@@ -88,10 +88,9 @@ 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 autoLoadRemoteContent = useStorage('autoLoadRemoteContent', false);
|
||||
const userJwt = useStorage('userJwt', '');
|
||||
const preferredLocale = useStorage('preferredLocale', '');
|
||||
const userTab = useSessionStorage('userTab', 'address_management');
|
||||
@@ -99,7 +98,6 @@ 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({
|
||||
@@ -163,10 +161,9 @@ export const useGlobalState = createGlobalState(
|
||||
adminMailTabAddress,
|
||||
adminSendBoxTabAddress,
|
||||
mailboxSplitSize,
|
||||
mailListView,
|
||||
mailListPreviewLineClamp,
|
||||
useIframeShowMail,
|
||||
preferShowTextMail,
|
||||
autoLoadRemoteContent,
|
||||
userJwt,
|
||||
preferredLocale,
|
||||
userTab,
|
||||
@@ -176,7 +173,6 @@ export const useGlobalState = createGlobalState(
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
useUTCDate,
|
||||
autoLoadRemoteImages,
|
||||
autoRefresh,
|
||||
configAutoRefreshInterval,
|
||||
telegramApp,
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
// @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="<img src=${T}>"></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,<img src=${T}>"></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://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);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
// @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('');
|
||||
});
|
||||
});
|
||||
@@ -80,4 +80,3 @@ export function getDownloadEmlUrl(raw) {
|
||||
new Blob([raw], { type: 'text/plain' }
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
export const sanitizeHtml = (html) => {
|
||||
return DOMPurify.sanitize(typeof html === 'string' ? html : '');
|
||||
};
|
||||
@@ -1,16 +1,13 @@
|
||||
<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="safeAnnouncement"></div>
|
||||
<div v-html="announcement"></div>
|
||||
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
|
||||
<template #icon>
|
||||
<n-icon :component="GithubAlt" />
|
||||
|
||||
@@ -11,8 +11,8 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const {
|
||||
mailboxSplitSize, mailListView, mailListPreviewLineClamp, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
|
||||
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteImages
|
||||
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
|
||||
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteContent
|
||||
} = useGlobalState()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
@@ -23,26 +23,12 @@ 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" :max="0.75" :step="0.01" :marks="{
|
||||
0: '0',
|
||||
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
|
||||
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'
|
||||
@@ -57,12 +43,12 @@ const { t } = useScopedI18n('views.common.Appearance')
|
||||
<n-form-item-row :label="t('useIframeShowMail')">
|
||||
<n-switch v-model:value="useIframeShowMail" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('autoLoadRemoteContent')">
|
||||
<n-switch v-model:value="autoLoadRemoteContent" :round="false" />
|
||||
</n-form-item-row>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
@@ -17,9 +17,6 @@ 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)
|
||||
@@ -49,11 +46,7 @@ const unbindAddress = async (address_id) => {
|
||||
body: JSON.stringify({ address_id })
|
||||
});
|
||||
message.success(t('unbindAddress') + " " + t('success'));
|
||||
if (page.value === 1) {
|
||||
await fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
@@ -78,11 +71,7 @@ const transferAddress = async () => {
|
||||
})
|
||||
});
|
||||
message.success(t('transferAddress') + " " + t('success'));
|
||||
if (page.value === 1) {
|
||||
await fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
await fetchData();
|
||||
showTranferAddress.value = false;
|
||||
currentAddressId.value = 0;
|
||||
currentAddress.value = "";
|
||||
@@ -95,17 +84,10 @@ const transferAddress = async () => {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
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()}`
|
||||
const { results } = await api.fetch(
|
||||
`/user_api/bind_address`
|
||||
);
|
||||
data.value = results;
|
||||
if (page.value === 1) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
@@ -196,10 +178,6 @@ const columns = [
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -219,12 +197,6 @@ watch([page, pageSize], 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>
|
||||
@@ -244,9 +216,4 @@ watch([page, pageSize], async () => {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@ const fetchMailData = async (limit, offset) => {
|
||||
const fetchAddresData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/user_api/bind_address?limit=100&offset=0`
|
||||
`/user_api/bind_address`
|
||||
);
|
||||
addressFilterOptions.value = results.map((item) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.119.0"
|
||||
"wrangler": "^4.104.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
set -euo pipefail
|
||||
|
||||
cd frontend/
|
||||
pnpm up
|
||||
pnpm add -D wrangler@latest
|
||||
@@ -19,18 +17,3 @@ 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 ..
|
||||
|
||||
@@ -7,4 +7,3 @@ 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
|
||||
|
||||
@@ -21,7 +21,6 @@ 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")
|
||||
|
||||
|
||||
@@ -12,6 +12,3 @@ 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
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
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
|
||||
@@ -3,12 +3,11 @@ import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from twisted.internet import defer, threads
|
||||
from twisted.internet import defer
|
||||
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
|
||||
@@ -52,12 +51,9 @@ class MessageCache:
|
||||
@implementer(imap4.IMailboxInfo, imap4.IMailbox, imap4.ISearchableMailbox)
|
||||
class SimpleMailbox:
|
||||
|
||||
def __init__(self, name: str, client: BackendClient, address: str,
|
||||
flag_store: FlagStore = None):
|
||||
def __init__(self, name: str, client: BackendClient):
|
||||
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
|
||||
@@ -80,10 +76,7 @@ class SimpleMailbox:
|
||||
return 0
|
||||
|
||||
def getUnseenCount(self):
|
||||
return sum(
|
||||
1 for u in self._uid_index
|
||||
if r"\Seen" not in self._flags.get(u, set())
|
||||
)
|
||||
return 0
|
||||
|
||||
def isWriteable(self):
|
||||
return 1
|
||||
@@ -130,7 +123,6 @@ class SimpleMailbox:
|
||||
if count == 0:
|
||||
self._uid_index = []
|
||||
self._uid_index_built = True
|
||||
self._flags = {}
|
||||
return
|
||||
|
||||
uid_set = set()
|
||||
@@ -154,11 +146,6 @@ 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),
|
||||
@@ -267,10 +254,9 @@ class SimpleMailbox:
|
||||
else:
|
||||
continue
|
||||
|
||||
# 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())
|
||||
if uid_val not in self._flags:
|
||||
self._flags[uid_val] = {r"\Seen"}
|
||||
flags = self._flags[uid_val]
|
||||
msg = SimpleMessage(
|
||||
uid_val, email_model, flags=flags, raw=raw,
|
||||
created_at=item.get("created_at"),
|
||||
@@ -323,20 +309,18 @@ 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, current_flags in updated_flags.items():
|
||||
|
||||
for u in target_uids:
|
||||
current_flags = self._flags.get(u, set())
|
||||
|
||||
if mode == 1: # +FLAGS
|
||||
current_flags = current_flags | set(flags)
|
||||
elif mode == -1: # -FLAGS
|
||||
current_flags = current_flags - set(flags)
|
||||
elif mode == 0: # FLAGS (replace)
|
||||
current_flags = set(flags)
|
||||
|
||||
self._flags[u] = current_flags
|
||||
seq = self._uid_to_seq(u)
|
||||
if seq is not None:
|
||||
@@ -344,58 +328,28 @@ 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()
|
||||
|
||||
predicates = []
|
||||
results = []
|
||||
|
||||
for term in query:
|
||||
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 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
|
||||
|
||||
matched = [
|
||||
u for u in self._uid_index
|
||||
if all(
|
||||
(flag in self._flags.get(u, set())) is present
|
||||
for flag, present in predicates
|
||||
)
|
||||
]
|
||||
if not results:
|
||||
if uid:
|
||||
results = list(self._uid_index)
|
||||
else:
|
||||
results = list(range(1, len(self._uid_index) + 1))
|
||||
|
||||
_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
|
||||
]
|
||||
return results
|
||||
|
||||
def getUIDNext(self):
|
||||
if self._uid_index:
|
||||
|
||||
@@ -48,49 +48,6 @@ 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:
|
||||
@@ -126,7 +83,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, self.name)
|
||||
return SimpleMailbox(name, self._client)
|
||||
|
||||
def create(self, pathspec):
|
||||
"""Accept CREATE silently without actually creating mailboxes."""
|
||||
@@ -154,8 +111,8 @@ class SimpleRealm:
|
||||
|
||||
client = BackendClient(password)
|
||||
|
||||
inbox = SimpleMailbox("INBOX", client, username)
|
||||
sent = SimpleMailbox("SENT", client, username)
|
||||
inbox = SimpleMailbox("INBOX", client)
|
||||
sent = SimpleMailbox("SENT", client)
|
||||
|
||||
account = Account(username)
|
||||
account._client = client
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
"""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()
|
||||
@@ -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: 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 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_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
|
||||
|
||||
@@ -64,7 +64,6 @@ 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,27 +49,6 @@ 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.
|
||||
@@ -160,36 +139,6 @@ 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Star History
|
||||
|
||||
<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" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
@@ -97,17 +97,14 @@
|
||||
| ------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| `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 | 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"]` |
|
||||
| `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"]` |
|
||||
| `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
|
||||
|
||||
@@ -64,7 +64,6 @@ 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,27 +49,6 @@ 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 删除单封邮件。
|
||||
@@ -160,36 +139,6 @@ print(response.json())
|
||||
- 用户 JWT 使用 `x-user-token: <jwt>` 访问 `/user_api/*` 接口
|
||||
:::
|
||||
|
||||
### 用户绑定地址列表
|
||||
|
||||
`GET /user_api/bind_address` 使用服务端分页,支持以下查询参数:
|
||||
|
||||
未携带分页参数时返回默认第一页,不支持一次获取全部绑定地址。
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `limit` | `20` | 每页数量,范围为 1–100 |
|
||||
| `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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Star History
|
||||
|
||||
<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" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
@@ -91,17 +91,14 @@
|
||||
| ------------------------------- | --------- | -------------------------------------------------------------------------- | -------------------------- |
|
||||
| `BLACK_LIST` | 文本 | 黑名单,用于过滤发件人,逗号分隔 | `gov.cn,edu.cn` |
|
||||
| `ENABLE_CHECK_JUNK_MAIL` | 文本/JSON | 是否启用垃圾邮件检查,配合下列两个列表使用 | `false` |
|
||||
| `JUNK_MAIL_CHECK_LIST` | JSON | 存在性检查;已注册的失败/错误结果判定为垃圾邮件,`none` 和 SPF/DKIM `neutral` 按不存在处理 | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | 强制通过检查;每一项都必须明确返回 `pass`,否则判定为垃圾邮件 | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_CHECK_LIST` | JSON | 垃圾邮件检查配置, 任何一项 `存在` 且 `不通过` 则被判定为垃圾邮件 | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | 垃圾邮件检查配置, 任何一项 `不存在` 或者 `不通过` 则被判定为垃圾邮件 | `["spf", "dkim", "dmarc"]` |
|
||||
| `FORWARD_ADDRESS_LIST` | JSON | 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址 | `["xxx@xxx.com"]` |
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB,则删除附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema` 或 `POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
|
||||
| `CLEANUP_BATCH_SIZE` | 数字 | 邮件、发件箱及按创建/活跃时间清理地址时的单次处理上限,默认 `3000`,有效范围 `1-5000`。较小值可降低单次 D1 压力,较大值可加快积压数据清理 | `3000` |
|
||||
|
||||
> [!NOTE]
|
||||
> 认证结果遵循各自规范:SPF `none` 表示没有可检查的域名或 SPF 记录,SPF `neutral` 必须与 `none` 相同处理;DKIM `none` 表示邮件未签名,DKIM `neutral` 同样按未签名处理;DMARC `none` 表示没有适用的 DMARC 策略。未注册结果和不支持的方法版本也会被忽略。`JUNK_MAIL_CHECK_LIST` 将这些结果视为认证方法不存在,`JUNK_MAIL_FORCE_PASS_LIST` 仍只接受明确且受支持的 `pass`
|
||||
>
|
||||
> `ENABLE_MAIL_GZIP` 会增加邮件写入压缩与读取解压的 CPU 消耗,免费版 Worker 更容易触发 CPU 限制,建议付费 Plan 再开启
|
||||
>
|
||||
> `垃圾邮件检查` 和 `移除附件功能` 需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "temp-mail-docs",
|
||||
"private": true,
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/node": "^26.0.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"wrangler": "^4.119.0"
|
||||
"wrangler": "^4.104.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
|
||||
1168
vitepress-docs/pnpm-lock.yaml
generated
1168
vitepress-docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.11.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,23 +11,23 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^5.20260804.1",
|
||||
"@cloudflare/workers-types": "^4.20260624.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@types/node": "^25.9.5",
|
||||
"@types/node": "^25.9.4",
|
||||
"eslint": "9.39.1",
|
||||
"globals": "^16.5.0",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"wrangler": "^4.119.0"
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"wrangler": "^4.104.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.888.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"hono": "^4.13.0",
|
||||
"hono": "^4.12.27",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"mimetext": "^3.0.28",
|
||||
"postal-mime": "^2.7.5",
|
||||
"resend": "^6.18.1",
|
||||
"postal-mime": "^2.7.4",
|
||||
"resend": "^6.14.0",
|
||||
"telegraf": "4.16.3",
|
||||
"worker-mailer": "^1.2.1"
|
||||
},
|
||||
|
||||
1463
worker/pnpm-lock.yaml
generated
1463
worker/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -70,38 +70,29 @@ const createNewAddress = async (c: Context<HonoCustomType>) => {
|
||||
const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
// single batch runs as one transaction: rows keyed by address name are
|
||||
// deleted first and the address row last, so the name subqueries still
|
||||
// resolve and a failed statement rolls back the whole deletion
|
||||
const results = await c.env.DB.batch([
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM sendbox WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM auto_reply_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ?`
|
||||
).bind(id),
|
||||
c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id),
|
||||
]);
|
||||
const success = results.every((result) => result.success);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id).run();
|
||||
if (!success) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
return c.json({ success })
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
const { success: sendAccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM address_sender WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
const { success: usersAddressSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM users_address WHERE address_id = ?`
|
||||
).bind(id).run();
|
||||
return c.json({
|
||||
success: success && mailSuccess && sendAccess && usersAddressSuccess
|
||||
})
|
||||
};
|
||||
|
||||
const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Context } from "hono";
|
||||
import { handleMailListQuery } from "../common";
|
||||
import { resolveRawEmailRow } from "../gzip";
|
||||
|
||||
export default {
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
@@ -25,14 +24,6 @@ export default {
|
||||
[], limit, offset
|
||||
);
|
||||
},
|
||||
getMail: async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const result = await c.env.DB.prepare(
|
||||
`SELECT * FROM raw_mails WHERE id = ?`
|
||||
).bind(id).first();
|
||||
if (!result) return c.json(null);
|
||||
return c.json(await resolveRawEmailRow(result));
|
||||
},
|
||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
|
||||
@@ -33,7 +33,6 @@ api.post('/admin/address/:id/reset_password', address_api.resetPassword)
|
||||
// mail api
|
||||
api.get('/admin/mails', admin_mail_api.getMails)
|
||||
api.get('/admin/mails_unknow', admin_mail_api.getUnknowMails)
|
||||
api.get('/admin/mails/:id', admin_mail_api.getMail)
|
||||
api.delete('/admin/mails/:id', admin_mail_api.deleteMail)
|
||||
|
||||
// address sender
|
||||
|
||||
@@ -250,34 +250,10 @@ export function updateAddressUpdatedAt(
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
try {
|
||||
await c.env.DB.prepare(
|
||||
`UPDATE address SET updated_at = datetime('now')`
|
||||
+ ` WHERE name = ?`
|
||||
+ ` AND (updated_at IS NULL OR updated_at < datetime('now', '-1 day'))`
|
||||
`UPDATE address SET updated_at = datetime('now') where name = ?`
|
||||
).bind(address).run();
|
||||
} catch (e) {
|
||||
const errorName = e instanceof Error ? e.name : "UnknownError";
|
||||
console.warn("[updateAddressUpdatedAt] failed:", errorName);
|
||||
}
|
||||
})());
|
||||
}
|
||||
|
||||
export function updateUserAddressesUpdatedAt(
|
||||
c: Context<HonoCustomType>,
|
||||
userId: number | string | undefined | null
|
||||
): void {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
try {
|
||||
await c.env.DB.prepare(
|
||||
`UPDATE address SET updated_at = datetime('now')`
|
||||
+ ` WHERE id IN (SELECT address_id FROM users_address WHERE user_id = ?)`
|
||||
+ ` AND (updated_at IS NULL OR updated_at < datetime('now', '-1 day'))`
|
||||
).bind(userId).run();
|
||||
} catch (e) {
|
||||
const errorName = e instanceof Error ? e.name : "UnknownError";
|
||||
console.warn("[updateUserAddressesUpdatedAt] failed:", errorName);
|
||||
console.warn("[updateAddressUpdatedAt] failed:", address, e);
|
||||
}
|
||||
})());
|
||||
}
|
||||
@@ -494,26 +470,18 @@ export const cleanup = async (
|
||||
if (!cleanType || typeof cleanDays !== 'number' || cleanDays < 0 || cleanDays > 1000) {
|
||||
throw new Error(msgs.InvalidCleanupConfigMsg)
|
||||
}
|
||||
let cleanupBatchSize = getIntValue(c.env.CLEANUP_BATCH_SIZE, 3000);
|
||||
if (!Number.isInteger(cleanupBatchSize) || cleanupBatchSize < 1 || cleanupBatchSize > 5000) {
|
||||
cleanupBatchSize = 3000;
|
||||
}
|
||||
console.log(`Cleanup ${cleanType} before ${cleanDays} days`);
|
||||
switch (cleanType) {
|
||||
case "inactiveAddress":
|
||||
await batchDeleteAddressWithData(
|
||||
c,
|
||||
`id IN (`
|
||||
+ `SELECT id FROM address WHERE updated_at < datetime('now', '-${cleanDays} day') `
|
||||
+ `ORDER BY updated_at, id LIMIT ${cleanupBatchSize})`
|
||||
`updated_at < datetime('now', '-${cleanDays} day')`
|
||||
)
|
||||
break;
|
||||
case "addressCreated":
|
||||
await batchDeleteAddressWithData(
|
||||
c,
|
||||
`id IN (`
|
||||
+ `SELECT id FROM address WHERE created_at < datetime('now', '-${cleanDays} day') `
|
||||
+ `ORDER BY created_at, id LIMIT ${cleanupBatchSize})`
|
||||
`created_at < datetime('now', '-${cleanDays} day')`
|
||||
)
|
||||
break;
|
||||
case "unboundAddress":
|
||||
@@ -524,13 +492,8 @@ export const cleanup = async (
|
||||
break;
|
||||
case "mails":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM raw_mails WHERE id IN (
|
||||
SELECT id FROM raw_mails
|
||||
WHERE created_at < datetime('now', ?)
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?
|
||||
)`
|
||||
).bind(`-${cleanDays} day`, cleanupBatchSize).run();
|
||||
DELETE FROM raw_mails WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "mails_unknow":
|
||||
await c.env.DB.prepare(`
|
||||
@@ -540,13 +503,8 @@ export const cleanup = async (
|
||||
break;
|
||||
case "sendbox":
|
||||
await c.env.DB.prepare(`
|
||||
DELETE FROM sendbox WHERE id IN (
|
||||
SELECT id FROM sendbox
|
||||
WHERE created_at < datetime('now', ?)
|
||||
ORDER BY created_at, id
|
||||
LIMIT ?
|
||||
)`
|
||||
).bind(`-${cleanDays} day`, cleanupBatchSize).run();
|
||||
DELETE FROM sendbox WHERE created_at < datetime('now', '-${cleanDays} day')`
|
||||
).run();
|
||||
break;
|
||||
case "emptyAddress":
|
||||
// Delete addresses that have no emails and were created more than N days ago
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const CONSTANTS = {
|
||||
VERSION: 'v' + '1.11.0',
|
||||
VERSION: 'v' + '1.10.0',
|
||||
|
||||
// DB Version
|
||||
DB_VERSION_KEY: 'db_version',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getBooleanValue, getStringArray } from "../utils";
|
||||
import { commonParseMail } from "../common";
|
||||
import { isJunkMailByHeaders } from "./junk_mail_policy";
|
||||
|
||||
export const check_if_junk_mail = async (
|
||||
env: Bindings, address: string,
|
||||
@@ -15,9 +14,50 @@ export const check_if_junk_mail = async (
|
||||
|
||||
const checkListWhenExist = getStringArray(env.JUNK_MAIL_CHECK_LIST);
|
||||
const forcePassList = getStringArray(env.JUNK_MAIL_FORCE_PASS_LIST);
|
||||
return isJunkMailByHeaders(
|
||||
parsedEmail.headers,
|
||||
checkListWhenExist,
|
||||
forcePassList,
|
||||
const passedList: string[] = [];
|
||||
const existList: string[] = [];
|
||||
|
||||
const headers = parsedEmail.headers;
|
||||
for (const header of headers) {
|
||||
if (!header["key"]) continue;
|
||||
if (!header["value"]) continue;
|
||||
|
||||
// check spf
|
||||
if (header["key"].toLowerCase() == "received-spf") {
|
||||
existList.push("spf");
|
||||
if (header["value"].toLowerCase().includes("pass")) {
|
||||
passedList.push("spf");
|
||||
}
|
||||
}
|
||||
|
||||
// check dkim and dmarc
|
||||
if (header["key"].toLowerCase() == "authentication-results") {
|
||||
if (header["value"].toLowerCase().includes("dkim=")) {
|
||||
existList.push("dkim");
|
||||
if (header["value"].toLowerCase().includes("dkim=pass")) {
|
||||
passedList.push("dkim");
|
||||
}
|
||||
}
|
||||
if (header["value"].toLowerCase().includes("dmarc=")) {
|
||||
existList.push("dmarc");
|
||||
if (header["value"].toLowerCase().includes("dmarc=pass")) {
|
||||
passedList.push("dmarc");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// check if all checkListWhenExist item passed when exist
|
||||
if (checkListWhenExist?.some(
|
||||
(checkName) => existList.includes(checkName.toLowerCase())
|
||||
&& !passedList.includes(checkName.toLowerCase())
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (forcePassList?.length == 0) return false;
|
||||
|
||||
// check force pass list
|
||||
return forcePassList.some(
|
||||
(checkName) => !passedList.includes(checkName.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isJunkMailByHeaders } from "./junk_mail_policy.ts";
|
||||
|
||||
const authenticationResultsHeader = (results) => [{
|
||||
key: "Authentication-Results",
|
||||
value: `mx.example; ${results}`,
|
||||
}];
|
||||
|
||||
test("issue #1084: dmarc=none does not reject an otherwise passing message", () => {
|
||||
const headers = authenticationResultsHeader(
|
||||
"spf=pass smtp.mailfrom=sender.example; " +
|
||||
"dkim=pass header.d=sender.example; " +
|
||||
"dmarc=none; arc=pass"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isJunkMailByHeaders(
|
||||
headers,
|
||||
["spf", "dkim", "dmarc"],
|
||||
["spf"],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
for (const [method, result] of [
|
||||
["spf", "none"],
|
||||
["spf", "neutral"],
|
||||
["dkim", "none"],
|
||||
["dkim", "neutral"],
|
||||
["dmarc", "none"],
|
||||
["dmarc", "neutral"],
|
||||
]) {
|
||||
test(`${method}=${result} is absent for JUNK_MAIL_CHECK_LIST`, () => {
|
||||
assert.equal(
|
||||
isJunkMailByHeaders(
|
||||
authenticationResultsHeader(`${method}=${result}`),
|
||||
[method],
|
||||
[],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
for (const method of ["spf", "dkim", "dmarc"]) {
|
||||
test(`${method}=fail is junk when the method is checked`, () => {
|
||||
assert.equal(
|
||||
isJunkMailByHeaders(
|
||||
authenticationResultsHeader(`${method}=fail`),
|
||||
[method],
|
||||
[],
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("JUNK_MAIL_FORCE_PASS_LIST requires an explicit pass result", () => {
|
||||
for (const result of ["none", "neutral", "fail"]) {
|
||||
assert.equal(
|
||||
isJunkMailByHeaders(
|
||||
authenticationResultsHeader(`spf=${result}`),
|
||||
[],
|
||||
["spf"],
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
isJunkMailByHeaders(
|
||||
authenticationResultsHeader("spf=pass"),
|
||||
[],
|
||||
["spf"],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
type AuthenticationResult = {
|
||||
method: string,
|
||||
result: string,
|
||||
version?: string,
|
||||
}
|
||||
|
||||
const supportedResults: Record<string, ReadonlySet<string>> = {
|
||||
spf: new Set(["none", "neutral", "pass", "fail", "softfail", "policy", "temperror", "permerror"]),
|
||||
dkim: new Set(["none", "neutral", "pass", "fail", "policy", "temperror", "permerror"]),
|
||||
dmarc: new Set(["none", "pass", "fail", "temperror", "permerror"]),
|
||||
};
|
||||
const receivedSpfResults = new Set([
|
||||
"none", "neutral", "pass", "fail", "softfail", "temperror", "permerror",
|
||||
]);
|
||||
|
||||
const parseAuthenticationResults = (value: string): AuthenticationResult[] => {
|
||||
const results: AuthenticationResult[] = [];
|
||||
const pattern = /(?:^|;)[ \t]*([a-z][a-z0-9_-]*)(?:[ \t]*\/[ \t]*(\d+))?[ \t]*=[ \t]*([a-z][a-z0-9_-]*)/gi;
|
||||
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
results.push({
|
||||
method: match[1].toLowerCase(),
|
||||
version: match[2],
|
||||
result: match[3].toLowerCase(),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
const normalizeCheckName = (value: string): string => value.trim().toLowerCase();
|
||||
|
||||
const trackAuthenticationResult = (
|
||||
method: string,
|
||||
result: string | undefined,
|
||||
existing: Set<string>,
|
||||
passed: Set<string>,
|
||||
) => {
|
||||
if (!result || !supportedResults[method]?.has(result)) return;
|
||||
|
||||
// SPF and DKIM neutral are absent-equivalent under RFC 7208 and RFC 8601.
|
||||
if (result === "none" || ((method === "spf" || method === "dkim") && result === "neutral")) return;
|
||||
|
||||
existing.add(method);
|
||||
if (result === "pass") {
|
||||
passed.add(method);
|
||||
}
|
||||
}
|
||||
|
||||
export const isJunkMailByHeaders = (
|
||||
headers: Record<string, string>[],
|
||||
checkListWhenExist: string[],
|
||||
forcePassList: string[],
|
||||
): boolean => {
|
||||
const passed = new Set<string>();
|
||||
const existing = new Set<string>();
|
||||
|
||||
for (const header of headers) {
|
||||
const key = header["key"]?.toLowerCase();
|
||||
const value = header["value"];
|
||||
if (!key || !value) continue;
|
||||
|
||||
if (key === "received-spf") {
|
||||
const result = value.match(/^[ \t]*([a-z][a-z0-9_-]*)/i)?.[1]?.toLowerCase();
|
||||
if (!result || !receivedSpfResults.has(result)) continue;
|
||||
trackAuthenticationResult("spf", result, existing, passed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key !== "authentication-results") continue;
|
||||
|
||||
for (const { method, result, version } of parseAuthenticationResults(value)) {
|
||||
if (method !== "spf" && method !== "dkim" && method !== "dmarc") continue;
|
||||
if (version && version !== "1") continue;
|
||||
trackAuthenticationResult(method, result, existing, passed);
|
||||
}
|
||||
}
|
||||
|
||||
if (checkListWhenExist.some((checkName) => {
|
||||
const normalizedName = normalizeCheckName(checkName);
|
||||
return existing.has(normalizedName) && !passed.has(normalizedName);
|
||||
})) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return forcePassList.some(
|
||||
(checkName) => !passed.has(normalizeCheckName(checkName))
|
||||
);
|
||||
}
|
||||
1
worker/src/types.d.ts
vendored
1
worker/src/types.d.ts
vendored
@@ -117,7 +117,6 @@ type Bindings = {
|
||||
|
||||
// gzip compression for raw_mails
|
||||
ENABLE_MAIL_GZIP: string | boolean | undefined
|
||||
CLEANUP_BATCH_SIZE: string | number | undefined
|
||||
|
||||
// E2E testing
|
||||
E2E_TEST_MODE: string | boolean | undefined
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Jwt } from 'hono/utils/jwt'
|
||||
import { isAddressCountLimitReached } from "../utils"
|
||||
import { unbindTelegramByAddress } from '../telegram_api/common';
|
||||
import i18n from '../i18n';
|
||||
import { updateAddressUpdatedAt, commonGetUserRole, handleListQuery, hideObjectFields } from '../common';
|
||||
import { updateAddressUpdatedAt, commonGetUserRole, hideObjectFields } from '../common';
|
||||
|
||||
const UserBindAddressModule = {
|
||||
bind: async (c: Context<HonoCustomType>) => {
|
||||
@@ -97,24 +97,16 @@ const UserBindAddressModule = {
|
||||
},
|
||||
getBindedAddresses: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { limit, offset } = c.req.query();
|
||||
const params = [String(user_id)];
|
||||
const fromQuery = ` FROM address a`
|
||||
+ ` JOIN users_address ua ON ua.address_id = a.id`
|
||||
+ ` WHERE ua.user_id = ?`;
|
||||
return await handleListQuery(
|
||||
c,
|
||||
`SELECT a.*,`
|
||||
+ ` (SELECT COUNT(*) FROM raw_mails WHERE address = a.name) AS mail_count,`
|
||||
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
|
||||
+ fromQuery,
|
||||
`SELECT COUNT(*) AS count${fromQuery}`,
|
||||
params,
|
||||
limit ?? 20,
|
||||
offset ?? 0,
|
||||
'a.id DESC',
|
||||
['password'],
|
||||
);
|
||||
const results = await UserBindAddressModule.getBindedAddressesById(c, user_id);
|
||||
return c.json({
|
||||
results: results,
|
||||
});
|
||||
},
|
||||
getBindedAddressListById: async (
|
||||
c: Context<HonoCustomType>, user_id: number | string
|
||||
): Promise<string[]> => {
|
||||
const bindedAddressList = await UserBindAddressModule.getBindedAddressesById(c, user_id);
|
||||
return bindedAddressList.map((item) => item.name);
|
||||
},
|
||||
getBindedAddressesById: async (
|
||||
c: Context<HonoCustomType>, user_id: number | string
|
||||
|
||||
@@ -4,7 +4,7 @@ import i18n from "../i18n";
|
||||
import { UserOauth2Settings, UserSettings } from "../models";
|
||||
import { getJsonSetting, getUserRoles } from "../utils"
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { commonGetUserRole, updateUserAddressesUpdatedAt } from "../common";
|
||||
import { commonGetUserRole } from "../common";
|
||||
import { Jwt } from "hono/utils/jwt";
|
||||
|
||||
export default {
|
||||
@@ -64,7 +64,17 @@ export default {
|
||||
exp: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
}, c.env.JWT_SECRET, "HS256");
|
||||
updateUserAddressesUpdatedAt(c, user.user_id);
|
||||
// update address updated_at asynchronously
|
||||
c.executionCtx.waitUntil((async () => {
|
||||
try {
|
||||
await c.env.DB.prepare(
|
||||
`UPDATE address SET updated_at = datetime('now') where id IN `
|
||||
+ `(SELECT address_id FROM users_address WHERE user_id = ?)`
|
||||
).bind(user.user_id).run();
|
||||
} catch (e) {
|
||||
console.warn("[user_api/settings] updateAddressUpdatedAt failed:", user.user_id, e);
|
||||
}
|
||||
})());
|
||||
return c.json({
|
||||
...user,
|
||||
is_admin: is_admin,
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { Context } from "hono";
|
||||
import i18n from "../i18n";
|
||||
import { handleMailListQuery } from "../common";
|
||||
import UserBindAddressModule from "./bind_address";
|
||||
import { getBooleanValue } from "../utils";
|
||||
|
||||
export default {
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
const { user_id } = c.get("userPayload");
|
||||
const { address, limit, offset } = c.req.query();
|
||||
const filterQuerys = [`ua.user_id = ?`];
|
||||
const filterParams = [String(user_id)];
|
||||
if (address) {
|
||||
filterQuerys.push(`rm.address = ?`);
|
||||
filterParams.push(address);
|
||||
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
|
||||
const addressList = address ? bindedAddressList.filter((item) => item == address) : bindedAddressList;
|
||||
const addressQuery = `address IN (${addressList.map(() => "?").join(",")})`;
|
||||
const addressParams = addressList;
|
||||
|
||||
// user must have at least one binded address to query mails
|
||||
if (addressList.length <= 0) {
|
||||
return c.json({ results: [], count: 0 });
|
||||
}
|
||||
const fromQuery = ` FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` JOIN raw_mails rm ON rm.address = a.name`
|
||||
+ ` WHERE ${filterQuerys.join(" AND ")}`;
|
||||
|
||||
const filterQuerys = [addressQuery].filter((item) => item).join(" and ");
|
||||
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
|
||||
const filterParams = [...addressParams]
|
||||
return await handleMailListQuery(c,
|
||||
`SELECT rm.*${fromQuery}`,
|
||||
`SELECT count(*) as count${fromQuery}`,
|
||||
filterParams, limit, offset, 'rm.id desc'
|
||||
`SELECT * FROM raw_mails ${finalQuery}`,
|
||||
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,
|
||||
filterParams, limit, offset
|
||||
);
|
||||
},
|
||||
deleteMail: async (c: Context<HonoCustomType>) => {
|
||||
@@ -30,14 +34,11 @@ export default {
|
||||
}
|
||||
const { id } = c.req.param();
|
||||
const { user_id } = c.get("userPayload");
|
||||
const bindedAddressList = await UserBindAddressModule.getBindedAddressListById(c, user_id);
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE id = ?`
|
||||
+ ` AND EXISTS (`
|
||||
+ `SELECT 1 FROM users_address ua`
|
||||
+ ` JOIN address a ON a.id = ua.address_id`
|
||||
+ ` WHERE ua.user_id = ? AND a.name = raw_mails.address`
|
||||
+ `)`
|
||||
).bind(id, user_id).run();
|
||||
+ ` and address IN (${bindedAddressList.map(() => "?").join(",")})`
|
||||
).bind(id, ...bindedAddressList).run();
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
|
||||
@@ -17,8 +17,6 @@ keep_vars = true
|
||||
# enable cron if you want set auto clean up
|
||||
# [triggers]
|
||||
# crons = [ "0 0 * * *" ]
|
||||
# Per-run limit for mail, sent-mail, and creation/activity-based address cleanup. Defaults to 3000, maximum 5000.
|
||||
# CLEANUP_BATCH_SIZE = 3000
|
||||
|
||||
# send_email = [
|
||||
# { name = "SEND_MAIL" },
|
||||
@@ -128,9 +126,9 @@ ENABLE_AUTO_REPLY = false
|
||||
# FRONTEND_URL = "https://xxxx.xxx"
|
||||
# Enable check junk mail
|
||||
# ENABLE_CHECK_JUNK_MAIL = false
|
||||
# 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 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_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
|
||||
|
||||
Reference in New Issue
Block a user