mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-28 03:28:43 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13b6ab051c | ||
|
|
f92b059aac | ||
|
|
5dbb6107dd | ||
|
|
dccca92928 | ||
|
|
005d74bfde | ||
|
|
aeb2ac687d | ||
|
|
108b8ef4ac | ||
|
|
2762741226 | ||
|
|
3ffa16234f | ||
|
|
3bcc0c19ce | ||
|
|
a3c62de42f | ||
|
|
624fc9bb96 | ||
|
|
12152fc893 | ||
|
|
a09ede8944 | ||
|
|
f9281818e9 | ||
|
|
5553c6484a | ||
|
|
d04c1a865d | ||
|
|
116ddc7324 | ||
|
|
2dcbad40ad | ||
|
|
95badf5aed | ||
|
|
b3666c0900 | ||
|
|
e499211197 | ||
|
|
342fe22e4f | ||
|
|
8c883b269f | ||
|
|
4c1e593d07 | ||
|
|
7eaa3b3b8e | ||
|
|
0581632b59 | ||
|
|
4ce22ef249 | ||
|
|
99b332345b | ||
|
|
565bb839db | ||
|
|
dbd1f8706d | ||
|
|
3f1d800e90 | ||
|
|
70b30c2494 | ||
|
|
1a1dd720c8 | ||
|
|
2d501d82cf | ||
|
|
7c57592742 | ||
|
|
41105ed803 | ||
|
|
c924b71a5c | ||
|
|
4f0b44de2e | ||
|
|
f6fcbe793c |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: cf-temp-mail-upgrade-dependencies
|
||||
description: Upgrade npm dependencies across all sub-packages of the project. Use when the user asks to upgrade/update dependencies, bump deps, refresh lockfiles, or update wrangler. Runs pnpm upgrades on frontend/, worker/, pages/, and vitepress-docs/.
|
||||
description: Upgrade npm dependencies across all sub-packages of the project. Use when the user asks to upgrade/update dependencies, bump deps, refresh lockfiles, or update wrangler. Runs pnpm upgrades on frontend/, worker/, pages/, and vitepress-docs/, plus npm upgrades on e2e/.
|
||||
---
|
||||
|
||||
# Upgrade Dependencies
|
||||
@@ -23,18 +23,21 @@ The script runs the following in order:
|
||||
| `worker/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `pages/` | `pnpm up` + `pnpm add -D wrangler@latest` |
|
||||
| `vitepress-docs/` | `pnpm up --latest` + `pnpm add -D wrangler@latest` |
|
||||
| `e2e/` | `npx --yes npm-check-updates@23.0.0 --upgrade` + `npm install` + Playwright image validation |
|
||||
|
||||
Note: `vitepress-docs/` uses `--latest` (crosses semver ranges); other packages upgrade within ranges only.
|
||||
Note: `vitepress-docs/` and `e2e/` upgrade to the latest versions and may cross semver ranges; other packages upgrade within ranges only. `e2e/` uses npm because it has a `package-lock.json`.
|
||||
|
||||
## Post-upgrade checklist
|
||||
|
||||
1. Inspect `git diff` on `package.json` / `pnpm-lock.yaml` files for reasonable changes.
|
||||
1. Inspect `git diff` on `package.json`, `pnpm-lock.yaml`, and `package-lock.json` files for reasonable changes.
|
||||
2. Verify builds in each sub-package:
|
||||
- `cd frontend && pnpm build`
|
||||
- `cd worker && pnpm build && pnpm lint`
|
||||
- `cd vitepress-docs && pnpm build`
|
||||
3. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
|
||||
4. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
|
||||
- `cd e2e && npm test`
|
||||
3. If Playwright changed, keep `e2e/Dockerfile.e2e` on the matching Playwright image version.
|
||||
4. If wrangler had a major version bump, check `worker/wrangler.toml` for any required syntax changes.
|
||||
5. Commit with Conventional Commits format, e.g. `chore: upgrade dependencies`.
|
||||
|
||||
## Do NOT
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ Upgrade the version number of the cloudflare_temp_email project.
|
||||
4. Update the `VERSION` constant in `worker/src/constants.ts`.
|
||||
5. Insert a new version placeholder at the top of `CHANGELOG.md`.
|
||||
6. Insert a new version placeholder at the top of `CHANGELOG_EN.md`.
|
||||
7. Rename the previous version heading in both changelogs from `## v{OLD_VERSION}(main)` to `## v{OLD_VERSION}` so only the new active development version keeps `(main)`.
|
||||
|
||||
## CHANGELOG format
|
||||
|
||||
@@ -44,7 +45,14 @@ In `CHANGELOG.md`, insert before the existing `## v{OLD_VERSION}(main)` line (i.
|
||||
|
||||
```
|
||||
|
||||
`CHANGELOG_EN.md` uses the same format.
|
||||
After inserting the new placeholder, update the old heading:
|
||||
|
||||
```diff
|
||||
-## v{OLD_VERSION}(main)
|
||||
+## v{OLD_VERSION}
|
||||
```
|
||||
|
||||
`CHANGELOG_EN.md` uses the same format and must receive the same old-heading update.
|
||||
|
||||
## Commit message format
|
||||
|
||||
@@ -53,4 +61,5 @@ feat: upgrade version to v{VERSION}
|
||||
|
||||
- Update version number to {VERSION} in all package.json files
|
||||
- Add v{VERSION} placeholder in CHANGELOG.md
|
||||
- Move (main) marker from v{OLD_VERSION} to v{VERSION}
|
||||
```
|
||||
|
||||
@@ -142,3 +142,6 @@ pnpm-lock.yaml
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
e2e/.e2e-pids
|
||||
|
||||
# SMTP/IMAP proxy persisted IMAP flags (local dev / bare-metal runs)
|
||||
smtp_proxy_server/data/
|
||||
|
||||
+82
-1
@@ -6,7 +6,88 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.9.0(main)
|
||||
## v1.12.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |邮件状态| 新增基于独立稀疏关联表的可选邮件状态功能,不修改原邮件表且历史邮件默认已读、未星标;已读状态与低写入量的星标功能使用独立开关,支持索引化组合筛选
|
||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126)
|
||||
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||
- fix: |用户发信| 用户地址发信接口支持角色无限额度
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||
|
||||
## v1.11.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| 在随机子域名允许范围内新增普通、随机和自定义子域名模式选择(issue #1108)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| 将邮箱主页的账户设置、删除账户等表述更正为邮箱地址操作,并明确发信权限与额度按当前邮箱地址独立管理
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |发送邮件| 补充用户账号、邮箱地址和发信权限的概念区别及按当前地址申请权限的操作说明
|
||||
|
||||
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103)
|
||||
|
||||
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103)
|
||||
|
||||
- feat: |Worker| 邮件、发件箱及按创建/活跃时间清理地址时改为分批处理,默认每次最多 3000 条并支持通过 `CLEANUP_BATCH_SIZE` 调整(上限 5000),减少单次扫描和删除量(issue #1103)
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试
|
||||
- fix: |E2E| 新增清理批次上限、后续批次继续执行、保留未过期数据及地址关联数据清理测试
|
||||
|
||||
## v1.10.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 新增 `GET /admin/mails/:id` 接口,支持管理员按邮件 ID 跨邮箱读取单封邮件,并兼容 gzip 压缩存储(issue #1096)
|
||||
- feat: |Frontend| 邮箱新增「邮箱全宽列表视图」开关(在外观设置中控制),开启后默认全宽列表展示邮件标题与正文预览,点击单封邮件再展开为双栏,再次点击同一封邮件可回到列表视图;多选模式下点击邮件会同步切换勾选状态与右侧预览,并禁用同邮件点击收回列表,展开时双栏左侧列表宽度仍遵循「邮箱双栏视图左侧列表宽度占比」配置;默认关闭,保留原有双栏行为
|
||||
- feat: |Frontend| 邮箱全宽列表视图新增「正文预览行数」配置(在外观设置中控制),可设置邮件正文预览的最大行数,默认 2 行,0 表示关闭预览
|
||||
- feat: |Frontend| 外观设置新增「自动加载邮件正文中的外部图片」开关,关闭后邮件预览(含全屏视图)会先经 DOMPurify 消毒,并以白名单策略处理所有可能发起请求的位置:仅保留可证明为本地的引用(`cid:`、`data:image/`、`blob:` 与站内相对路径),其余一律阻断;`base`、`meta`、`script`、`link`、`iframe`、`object`、`embed`、`noscript` 等会自行取用资源或改变解析基准的元素在此模式下移除,`<style>` 保留但其中 `url()`、`image-set()`、`@import` 的远端引用会被替换。正文上方显示已阻断资源数量的提示条,可一键按封加载;默认保持开启,行为与此前一致(issue #1073)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `<a>` 与 `<area>` 的外部导航链接,并阻断通过 CSS 转义函数名或 at-rule 绕过远程资源过滤的情况
|
||||
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
|
||||
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测:SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
|
||||
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
|
||||
- fix: |AI 提取| 强化提示词,要求 AI 保持邮件原始链接域名,避免小模型改写验证链接域名导致错误跳转(issue #1072)
|
||||
- fix: |AI 提取| HTML-only 邮件在发送给 Workers AI 前会先压缩为可读文本,避免样式模板过长导致验证码位于 4000 字截断之后而无法识别
|
||||
- fix: |Frontend| 移动端 Header 增加页头内边距,避免标题、菜单按钮与屏幕边缘过近
|
||||
- fix: |IMAP 代理| 修复 IMAP `STORE` 无法真正标记邮件已读的问题:邮件不再硬编码为 `\Seen`,且 `SimpleMailbox` 的 flags 变更现持久化到本地 SQLite(新增 `imap_flag_db_path` 配置),使已读/未读状态可在客户端断线重连(如 Thunderbird 轮询)后保留,而非每次新建连接即丢失(issue #1074)
|
||||
- fix: |IMAP 代理| 修复 `SEARCH UNSEEN` 返回全部邮件的问题:`SimpleMailbox.search()` 现按持久化的 flags 计算 `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` 及其否定形式,多个条件按 AND 组合;无法识别的检索条件仍沿用原有行为返回全部邮件
|
||||
- fix: |IMAP 代理| 修复取信不会自动标记已读的问题:`BODY[...]`、`RFC822`、`RFC822.TEXT` 取信现按 RFC 3501 自动置 `\Seen`,而 `BODY.PEEK[...]`、`RFC822.HEADER` 及仅取元数据(如 `FLAGS`)不会
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |Worker| 新增 junk_mail_policy 回归测试(issue #1084):覆盖 SPF/DKIM/DMARC 的 `none`/`neutral` 按认证方法不存在处理、明确 `fail` 仍被拒收,以及 `JUNK_MAIL_FORCE_PASS_LIST` 仅接受明确 `pass`
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |README| 新增完整日文 README,并在中文和英文 README 中添加日文导航链接
|
||||
- feat: |Frontend| 「邮箱双栏视图左侧列表宽度占比」最小值由 0.25 放宽至 0,左侧列表可完全折叠使正文近乎全屏,刻度增加 0 点;收件箱与发件箱的双栏拆分同步生效,并优化外观设置文案以明确该比例控制左侧邮件列表宽度
|
||||
|
||||
## v1.9.0
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
+82
-1
@@ -6,7 +6,88 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.9.0(main)
|
||||
## v1.12.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Mail State| Add optional mail states backed by a separate sparse relation table without changing the raw-mail table; read status and low-write Flagged mail use independent switches and support indexed combined filters
|
||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||
- feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
|
||||
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
||||
- fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder
|
||||
- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs
|
||||
|
||||
### Improvements
|
||||
|
||||
- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
||||
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
||||
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
||||
|
||||
## v1.11.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| Add Normal, Random, and Custom subdomain mode selection within the random-subdomain scope (issue #1108)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| Relabel mailbox settings and deletion actions as email-address operations, and clarify that send permission and balance are managed independently for the current email address
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |Send Mail| Document the differences between user accounts, email addresses, and send permission, including how to request permission for the currently selected address
|
||||
|
||||
- fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103)
|
||||
|
||||
- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103)
|
||||
|
||||
- feat: |Worker| Process mail, sent-mail, and creation/activity-based address cleanup in batches of 3000 by default, configurable through `CLEANUP_BATCH_SIZE` up to 5000, reducing per-run scans and deletes (issue #1103)
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps
|
||||
- fix: |E2E| Cover cleanup batch limits, continuation on later runs, preservation of recent data, and address-related data cleanup
|
||||
|
||||
## v1.10.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add `GET /admin/mails/:id` for administrators to fetch a single mail by ID across mailboxes, including gzip-compressed storage support (issue #1096)
|
||||
- feat: |Frontend| Add a "Full-width mailbox list view" toggle in Appearance settings. When enabled, the mailbox shows a full-width list of subjects and body previews by default; clicking a mail expands it into the two-pane split view, clicking the same mail again returns to the list view; in multi-select mode, clicking a mail updates both its checked state and the right-side preview while disabling same-mail collapse, and the split width still follows the "Left list width in two-column mailbox view" setting. Defaults to off, preserving the original two-pane behavior
|
||||
- feat: |Frontend| Add "Body Preview Lines" in Appearance settings for the full-width mailbox list view, allowing runtime control over the body-preview clamp. It defaults to 2 lines, and 0 disables previews
|
||||
- feat: |Frontend| Add an "Automatically load external images in mail body" toggle in Appearance settings. When disabled, the mail body (including fullscreen view) is run through DOMPurify and an allowlist policy: only references that can be *proven* local are kept (`cid:`, `data:image/`, `blob:` and same-origin relative paths), everything else is blocked. Elements that fetch on their own or change how relative URLs resolve — `base`, `meta`, `script`, `link`, `iframe`, `object`, `embed`, `noscript` — are removed in this mode, while `<style>` is kept with remote `url()`, `image-set()` and `@import` references substituted. A banner above the body reports how many resources were blocked and loads them for that mail on demand; defaults to on, preserving the previous behavior (issue #1073)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| Preserve external navigation links on `<a>` and `<area>` elements when automatic remote-image loading is disabled, and block remote CSS resources hidden behind escaped function or at-rule names
|
||||
- fix: |Frontend| Sanitize HTML announcements in both the About page and startup notification through a shared DOMPurify helper, preventing executable tags or event attributes in `ANNOUNCEMENT` from causing XSS
|
||||
- fix: |Worker| Align junk-mail checking with authentication standards: treat SPF, DKIM, and DMARC `none` plus SPF/DKIM `neutral` as absent, and ignore unregistered results and unsupported method versions; `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
|
||||
- fix: |Admin| When deleting an address from the admin panel, delete its mails, sender records, sendbox and auto-reply entries before removing the address row itself; previously the address row was deleted first, so the name-based subqueries matched nothing and the mails were left orphaned in the database
|
||||
- fix: |AI Extract| Strengthen the prompt to keep original link domains from the email, preventing small models from rewriting verification-link domains (issue #1072)
|
||||
- fix: |AI Extract| Convert HTML-only mail bodies into compact readable text before sending them to Workers AI, preventing long templates from pushing verification codes past the 4000-character truncation window
|
||||
- fix: |Frontend| Add mobile Header page padding so the title and menu button no longer sit too close to the screen edge
|
||||
- fix: |IMAP Proxy| Fix IMAP `STORE` not actually marking mail as read: messages are no longer hardcoded to `\Seen`, and `SimpleMailbox` flag changes are now persisted to a local SQLite file (new `imap_flag_db_path` setting) so the read/unread state survives a client disconnect and reconnect (e.g. Thunderbird polling) instead of resetting on every new connection (issue #1074)
|
||||
- fix: |IMAP Proxy| Fix `SEARCH UNSEEN` returning every message: `SimpleMailbox.search()` now evaluates `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` and their negations against the persisted flags, combining multiple keys with AND; search keys it cannot evaluate keep the previous behaviour of matching everything
|
||||
- fix: |IMAP Proxy| Fix fetches never marking mail as read: `BODY[...]`, `RFC822` and `RFC822.TEXT` fetches now set `\Seen` per RFC 3501, while `BODY.PEEK[...]`, `RFC822.HEADER` and metadata-only fetches (e.g. `FLAGS`) do not
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |Worker| Add junk_mail_policy regression tests (issue #1084): `none`/`neutral` results for SPF/DKIM/DMARC are treated as the method being absent, explicit `fail` results are still rejected, and `JUNK_MAIL_FORCE_PASS_LIST` only accepts an explicit `pass`
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |README| Add a complete Japanese README and Japanese navigation links to the Chinese and English READMEs
|
||||
- feat: |Frontend| Lower the "Left list width in two-column mailbox view" minimum from 0.25 to 0 so the left list pane can fully collapse for a near-fullscreen content view, with a 0 mark added; applies to both the inbox and send-box two-pane splits, and clarifies the Appearance setting label so it is clear the value controls the left mail list width
|
||||
|
||||
## v1.9.0
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a>
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
</p>
|
||||
|
||||
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
|
||||
@@ -74,9 +75,9 @@
|
||||
<summary>Star History(点击收缩/展开)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
+5
-4
@@ -30,7 +30,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a>
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
</p>
|
||||
|
||||
> This project is for learning and personal use only. Please do not use it for any illegal activities, or you will be responsible for the consequences.
|
||||
@@ -74,9 +75,9 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
<summary>Star History (Click to expand/collapse)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
<!-- markdownlint-disable-file MD033 MD045 -->
|
||||
# Cloudflare 一時メール - 無料で構築できる一時メールサービス
|
||||
|
||||
<p align="center">
|
||||
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
|
||||
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
|
||||
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
|
||||
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
|
||||
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
<a href="">
|
||||
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
|
||||
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="Featured|HelloGitHub" height="30"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">中文文档</a> |
|
||||
<a href="README_EN.md">English Document</a> |
|
||||
<a href="README_JA.md">日本語ドキュメント</a>
|
||||
</p>
|
||||
|
||||
> 本プロジェクトは学習および個人利用のみを目的としています。違法行為には使用しないでください。使用した場合、その結果については利用者自身が責任を負うものとします。
|
||||
|
||||
**機能の充実した一時メールサービスです!**
|
||||
|
||||
- **完全無料** - Cloudflare の無料サービス上に構築され、運用コストはかかりません
|
||||
- **高性能** - Rust WASM によるメール解析で極めて高速に応答します
|
||||
- **モダンな UI** - 多言語対応のレスポンシブデザインで、簡単に操作できます
|
||||
- **アドレスパスワード** - メールアドレスごとに個別のパスワードを設定し、セキュリティを強化できます
|
||||
- **Agent フレンドリー** - AI agent がメールボックスを利用できる組み込みの [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) を提供します
|
||||
- **モバイル管理** - Android の管理画面とメールボックス管理に対応したコミュニティクライアント [CloudMail](https://github.com/Lur1N77777/CloudMail) を利用できます
|
||||
|
||||
## デプロイドキュメント - クイックスタート
|
||||
|
||||
[ドキュメント](https://temp-mail-docs.awsl.uk) | [GitHub Actions デプロイガイド](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
|
||||
|
||||
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
|
||||
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
|
||||
</a>
|
||||
|
||||
## 変更履歴
|
||||
|
||||
最新の更新内容については [CHANGELOG](CHANGELOG.md) を参照してください。
|
||||
|
||||
## ライブデモ
|
||||
|
||||
今すぐ試す → [https://mail.awsl.uk/](https://mail.awsl.uk/)
|
||||
|
||||
<details>
|
||||
<summary>サービス稼働状況(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
| | |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Backend](https://temp-email-api.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml)       |
|
||||
| [Frontend](https://mail.awsl.uk/) | [](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml)       |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Star History(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>目次(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
- [Cloudflare 一時メール - 無料で構築できる一時メールサービス](#cloudflare-一時メール---無料で構築できる一時メールサービス)
|
||||
- [デプロイドキュメント - クイックスタート](#デプロイドキュメント---クイックスタート)
|
||||
- [変更履歴](#変更履歴)
|
||||
- [ライブデモ](#ライブデモ)
|
||||
- [主な機能](#主な機能)
|
||||
- [メール処理](#メール処理)
|
||||
- [ユーザー管理](#ユーザー管理)
|
||||
- [管理機能](#管理機能)
|
||||
- [多言語対応とインターフェース](#多言語対応とインターフェース)
|
||||
- [連携と拡張](#連携と拡張)
|
||||
- [技術アーキテクチャ](#技術アーキテクチャ)
|
||||
- [システム構成](#システム構成)
|
||||
- [技術スタック](#技術スタック)
|
||||
- [主要コンポーネント](#主要コンポーネント)
|
||||
- [コミュニティに参加](#コミュニティに参加)
|
||||
|
||||
</details>
|
||||
|
||||
## 主な機能
|
||||
|
||||
<details open>
|
||||
<summary>主な機能の詳細(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
### メール処理
|
||||
|
||||
- [x] `rust wasm` でメールを高速に解析します。ほぼすべてのメールを解析でき、Node.js の解析モジュールで失敗するメールも rust wasm なら正常に解析できます
|
||||
- [x] **AI メール認識** - Cloudflare Workers AI を使用して、メール内の確認コード、認証リンク、サービスリンクなどの重要情報を自動抽出します
|
||||
- [x] 指定したベースドメインに対して、ランダムな第 2 レベルサブドメインのメールボックスを任意で作成できます
|
||||
- [x] `DKIM` 検証付きのメール送信に対応します
|
||||
- [x] `SMTP` や `Resend` など、複数の送信方法に対応します
|
||||
- [x] 添付ファイルの表示機能を備え、添付画像も表示できます
|
||||
- [x] S3 での添付ファイルの保存と削除に対応します
|
||||
- [x] スパム検出とブラックリスト/ホワイトリストの設定に対応します
|
||||
- [x] グローバル転送先アドレスを指定できるメール転送機能を備えています
|
||||
|
||||
### ユーザー管理
|
||||
|
||||
- [x] `credentials` を使用して、以前利用したメールボックスへ再ログインできます
|
||||
- [x] 完全なユーザー登録・ログイン機能を備えています。メールアドレスを紐付けると、そのアドレスのメール JWT 資格情報を自動取得し、複数のメールボックスを切り替えられます
|
||||
- [x] `OAuth2` によるサードパーティログイン(Github、Authentik など)に対応します
|
||||
- [x] `Passkey` によるパスワードレスログインに対応します
|
||||
- [x] 複数ロールのドメインおよびプレフィックスを設定できるユーザーロール管理に対応します
|
||||
- [x] アドレスやキーワードで絞り込めるユーザー受信箱を提供します
|
||||
|
||||
### 管理機能
|
||||
|
||||
- [x] 完全な admin コンソールを備えています
|
||||
- [x] `admin` バックエンドからプレフィックスのないメールボックスを作成できます
|
||||
- [x] admin ユーザー管理画面でユーザーのアドレスを確認できます
|
||||
- [x] 複数のクリーンアップ戦略を選べる定期クリーンアップ機能を備えています
|
||||
- [x] カスタム名のメールボックスを取得でき、`admin` でブラックリストを設定できます
|
||||
- [x] アクセスパスワードを追加し、プライベートサイトとして利用できます
|
||||
|
||||
### 多言語対応とインターフェース
|
||||
|
||||
- [x] フロントエンドとバックエンドの両方が多言語に対応しています
|
||||
- [x] レスポンシブレイアウトのモダンな UI デザインを採用しています
|
||||
- [x] Google Ads の連携に対応します
|
||||
- [x] shadow DOM を使用してスタイルの干渉を防ぎます
|
||||
- [x] URL の JWT パラメーターによる自動ログインに対応します
|
||||
|
||||
### 連携と拡張
|
||||
|
||||
- [x] 完全な `Telegram Bot`、`Telegram` プッシュ通知、Telegram Bot ミニアプリに対応します
|
||||
- [x] `SMTP proxy server` を追加し、`SMTP` によるメール送信と `IMAP` によるメール閲覧に対応します
|
||||
- [x] Webhook とメッセージプッシュ連携に対応します
|
||||
- [x] `CF Turnstile` CAPTCHA 検証に対応します
|
||||
- [x] 悪用を防ぐためのレート制限を設定できます
|
||||
- [x] **Agent フレンドリー**:組み込みの [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill により、AI agent がメールボックスを直接利用できます。詳しくは[ドキュメント](vitepress-docs/docs/en/guide/feature/agent-email.md)を参照してください
|
||||
- [x] コミュニティのモバイル管理クライアント:[CloudMail](https://github.com/Lur1N77777/CloudMail) は本プロジェクト互換の API 向けに Expo / React Native で構築されており、Android 管理コンソール、アドレス管理、受信済み/送信済み/不明メール、確認コードのクイックコピー、OLED ブラックテーマ、ローカルグループ分けを提供します
|
||||
|
||||
</details>
|
||||
|
||||
## 技術アーキテクチャ
|
||||
|
||||
<details>
|
||||
<summary>技術アーキテクチャの詳細(クリックして展開/折りたたみ)</summary>
|
||||
|
||||
### システム構成
|
||||
|
||||
- **データベース**:メインデータベースとして Cloudflare D1 を使用
|
||||
- **フロントエンドのデプロイ**:Cloudflare Pages を使用してフロントエンドをデプロイ
|
||||
- **バックエンドのデプロイ**:Cloudflare Workers を使用してバックエンドをデプロイ
|
||||
- **メールルーティング**:Cloudflare Email Routing を使用
|
||||
|
||||
### 技術スタック
|
||||
|
||||
- **フロントエンド**:Vue 3 + Vite + TypeScript
|
||||
- **バックエンド**:TypeScript + Cloudflare Workers
|
||||
- **メール解析**:Rust WASM(mail-parser-wasm)
|
||||
- **データベース**:Cloudflare D1(SQLite)
|
||||
- **ストレージ**:Cloudflare KV + R2(任意で S3)
|
||||
- **プロキシサービス**:Python SMTP/IMAP Proxy Server
|
||||
|
||||
### 主要コンポーネント
|
||||
|
||||
- **Worker**:中核となるバックエンドサービス
|
||||
- **Frontend**:Vue 3 ユーザーインターフェース
|
||||
- **Mail Parser WASM**:Rust メール解析モジュール
|
||||
- **SMTP Proxy Server**:Python メールプロキシサービス
|
||||
- **Pages Functions**:Cloudflare Pages ミドルウェア
|
||||
- **Documentation**:VitePress ドキュメントサイト
|
||||
|
||||
</details>
|
||||
|
||||
### 重要な注意事項
|
||||
|
||||
- Resend でドメインレコードを追加する際、DNS プロバイダーが第 3 レベルドメイン a.b.com をホストしている場合は、Resend が生成したデフォルト名から第 2 レベルドメインのプレフィックス b を削除してください。削除しないと a.b.b.com が追加され、検証に失敗します。レコードを追加した後、次のコマンドで確認できます。
|
||||
```bash
|
||||
nslookup -qt="mx" a.b.com 1.1.1.1
|
||||
```
|
||||
|
||||
## コミュニティに参加
|
||||
|
||||
- [Telegram](https://t.me/cloudflare_temp_email)
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC);
|
||||
@@ -15,6 +15,15 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Keep this version in sync with @playwright/test in package.json
|
||||
FROM mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
|
||||
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ keep_vars = true
|
||||
[vars]
|
||||
PREFIX = "TMP"
|
||||
DEFAULT_DOMAINS = []
|
||||
DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
DOMAINS = ["TEST.EXAMPLE.COM", "MANUAL.EXAMPLE.COM"]
|
||||
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
|
||||
USER_ROLES = [
|
||||
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
|
||||
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||
@@ -16,13 +18,17 @@ JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_MAIL_READ_STATUS = true
|
||||
ENABLE_MAIL_FLAGGED = true
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
NO_LIMIT_SEND_ROLE = "case-role"
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
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}}
|
||||
"""
|
||||
|
||||
@@ -20,6 +20,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_GZIP = true
|
||||
ENABLE_MAIL_READ_STATUS = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
@@ -24,6 +24,7 @@ DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
ENABLE_MAIL_FLAGGED = true
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
Generated
+74
-93
@@ -6,14 +6,14 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
"imapflow": "^1.7.1",
|
||||
"nodemailer": "^9.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -23,35 +23,35 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -69,13 +69,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
||||
"version": "5.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.15.tgz",
|
||||
"integrity": "sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libmime": "5.4.2",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -113,9 +113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -129,38 +129,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
|
||||
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.1.tgz",
|
||||
"integrity": "sha512-etpyRH7wOPQGawjl/5ChgaaX5NRK2fxJAi9EFFNwv0N3FXsvjsL3Qu5dJ4i/myH61Cq2RSTb6vI5T9rXDbXD+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"@zone-eu/mailsplit": "5.4.15",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.8",
|
||||
"libmime": "5.4.2",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.5",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.7"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
"socks": "2.8.9"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -173,29 +160,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.2.tgz",
|
||||
"integrity": "sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
@@ -203,9 +178,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -258,41 +233,41 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -346,12 +321,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -378,28 +353,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+6
-6
@@ -7,13 +7,13 @@
|
||||
"test:down": "docker compose down -v"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
"imapflow": "^1.7.1",
|
||||
"nodemailer": "^9.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,34 @@ test.describe('Mail Gzip Storage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('gzip-compressed mail is readable through admin detail API', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-detail');
|
||||
try {
|
||||
await receiveGzipMail(request, address, {
|
||||
subject: 'Gzip Admin Detail Test',
|
||||
text: 'admin compressed content',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const { results } = await listRes.json();
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_GZIP_URL}/admin/mails/${mailId}`, {
|
||||
headers: { 'x-admin-auth': 'e2e-admin-pass' },
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.raw).toContain('Gzip Admin Detail Test');
|
||||
expect(mail.raw).toContain('admin compressed content');
|
||||
expect(mail.raw_blob).toBeUndefined();
|
||||
} finally {
|
||||
await deleteGzipAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
|
||||
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const waitForNextTimestamp = () => new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
|
||||
test.describe('Address activity throttling', () => {
|
||||
test('does not rewrite recently active addresses from user settings', async ({ request }) => {
|
||||
const email = `activity-throttle-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const address = await createTestAddress(request, 'activity-throttle');
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { enable: true, enableMailVerify: false },
|
||||
});
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
userId = payload.user_id;
|
||||
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${address.jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
|
||||
const beforeRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(beforeRes.ok()).toBe(true);
|
||||
const before = await beforeRes.json();
|
||||
const initialUpdatedAt = before.results.find(
|
||||
(row: { name: string }) => row.name === address.address,
|
||||
)?.updated_at;
|
||||
expect(initialUpdatedAt).toBeTruthy();
|
||||
|
||||
await waitForNextTimestamp();
|
||||
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(userSettingsRes.ok()).toBe(true);
|
||||
await waitForNextTimestamp();
|
||||
|
||||
const afterRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(afterRes.ok()).toBe(true);
|
||||
const after = await afterRes.json();
|
||||
const updatedAt = after.results.find(
|
||||
(row: { name: string }) => row.name === address.address,
|
||||
)?.updated_at;
|
||||
expect(updatedAt).toBe(initialUpdatedAt);
|
||||
} finally {
|
||||
await deleteAddress(request, address.jwt);
|
||||
if (userId) {
|
||||
const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
expect(deleteUserRes.ok()).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test.describe('Admin Config and D1 Storage', () => {
|
||||
test('reports database size in the database status response', async ({ request }) => {
|
||||
const response = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
expect(body.database_size).toEqual(expect.any(Number));
|
||||
expect(body.database_size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('saves and retrieves a namespaced config value', async ({ request }) => {
|
||||
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'd1_storage_plan', value: 'free' },
|
||||
});
|
||||
|
||||
expect(saveResponse.ok()).toBe(true);
|
||||
expect(await saveResponse.json()).toEqual({
|
||||
success: true,
|
||||
key: 'd1_storage_plan',
|
||||
value: 'free',
|
||||
});
|
||||
|
||||
const getResponse = await request.get(`${WORKER_URL}/admin/config/d1_storage_plan`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
|
||||
expect(getResponse.ok()).toBe(true);
|
||||
expect(await getResponse.json()).toEqual({
|
||||
key: 'd1_storage_plan',
|
||||
value: 'free',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps config values isolated from internal settings', async ({ request }) => {
|
||||
const versionBeforeResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
const versionBefore = (await versionBeforeResponse.json()).current_db_version;
|
||||
|
||||
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'db_version', value: 'shadow-version' },
|
||||
});
|
||||
expect(saveResponse.ok()).toBe(true);
|
||||
|
||||
const versionAfterResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect((await versionAfterResponse.json()).current_db_version).toBe(versionBefore);
|
||||
});
|
||||
|
||||
test('rejects invalid keys and non-string values', async ({ request }) => {
|
||||
const invalidKeyResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'invalid key', value: 'value' },
|
||||
});
|
||||
expect(invalidKeyResponse.status()).toBe(400);
|
||||
|
||||
const invalidValueResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'valid_key', value: 1 },
|
||||
});
|
||||
expect(invalidValueResponse.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const listMails = async (request: APIRequestContext, address: string) => {
|
||||
const response = await request.get(`${WORKER_URL}/admin/mails`, {
|
||||
params: { address, limit: '100', offset: '0' },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
test.describe('Bounded cleanup', () => {
|
||||
test('cleans at most one batch and continues on the next run', async ({ request }) => {
|
||||
const address = `cleanup-batch-${Date.now()}@test.example.com`;
|
||||
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
|
||||
request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: {
|
||||
address,
|
||||
raw: 'old cleanup mail',
|
||||
message_id: `<cleanup-old-${Date.now()}-${index}@test>`,
|
||||
},
|
||||
})
|
||||
));
|
||||
expect(seedResponses.every((response) => response.ok())).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
const firstCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'mails', cleanDays: 0 },
|
||||
});
|
||||
expect(firstCleanup.ok()).toBe(true);
|
||||
|
||||
const afterFirstCleanup = await listMails(request, address);
|
||||
expect(afterFirstCleanup.count).toBe(1);
|
||||
|
||||
const secondCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'mails', cleanDays: 0 },
|
||||
});
|
||||
expect(secondCleanup.ok()).toBe(true);
|
||||
|
||||
const afterSecondCleanup = await listMails(request, address);
|
||||
expect(afterSecondCleanup.count).toBe(0);
|
||||
|
||||
const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: {
|
||||
address,
|
||||
raw: 'recent cleanup mail',
|
||||
message_id: `<cleanup-recent-${Date.now()}@test>`,
|
||||
},
|
||||
});
|
||||
expect(recentMailResponse.ok()).toBe(true);
|
||||
|
||||
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'mails', cleanDays: 1 },
|
||||
});
|
||||
expect(recentCleanup.ok()).toBe(true);
|
||||
|
||||
const recentMails = await listMails(request, address);
|
||||
expect(recentMails.count).toBe(1);
|
||||
expect(recentMails.results[0].raw).toBe('recent cleanup mail');
|
||||
await request.delete(`${WORKER_URL}/admin/mails/${recentMails.results[0].id}`);
|
||||
});
|
||||
|
||||
test('deletes one address batch and its related data', async ({ request }) => {
|
||||
const oldAddress = await createTestAddress(request, 'cleanup-old');
|
||||
|
||||
const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: {
|
||||
address: oldAddress.address,
|
||||
raw: 'address cleanup mail',
|
||||
message_id: `<cleanup-address-${Date.now()}@test>`,
|
||||
},
|
||||
});
|
||||
expect(seedResponse.ok()).toBe(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
const cleanupResponse = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'addressCreated', cleanDays: 0 },
|
||||
});
|
||||
expect(cleanupResponse.ok()).toBe(true);
|
||||
|
||||
const oldAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { query: oldAddress.address, limit: '20', offset: '0' },
|
||||
});
|
||||
expect(oldAddressResponse.ok()).toBe(true);
|
||||
expect((await oldAddressResponse.json()).count).toBe(0);
|
||||
expect((await listMails(request, oldAddress.address)).count).toBe(0);
|
||||
|
||||
const recentAddress = await createTestAddress(request, 'cleanup-recent');
|
||||
try {
|
||||
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
|
||||
data: { cleanType: 'addressCreated', cleanDays: 1 },
|
||||
});
|
||||
expect(recentCleanup.ok()).toBe(true);
|
||||
|
||||
const recentAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
|
||||
params: { query: recentAddress.address, limit: '20', offset: '0' },
|
||||
});
|
||||
expect(recentAddressResponse.ok()).toBe(true);
|
||||
expect((await recentAddressResponse.json()).count).toBe(1);
|
||||
} finally {
|
||||
await deleteAddress(request, recentAddress.jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test.describe('Mail Detail', () => {
|
||||
test('fetch a single mail by ID', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'detail-get');
|
||||
@@ -53,3 +55,46 @@ test.describe('Mail Detail', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Admin Mail Detail', () => {
|
||||
test('fetch a single mail by ID without a mailbox JWT', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'admin-detail-get');
|
||||
|
||||
try {
|
||||
await seedTestMail(request, address, {
|
||||
subject: 'Admin Detail Test',
|
||||
from: 'admin-detail@test.example.com',
|
||||
text: 'Hello admin detail',
|
||||
});
|
||||
|
||||
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const { results } = await listRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
const mailId = results[0].id;
|
||||
|
||||
const detailRes = await request.get(`${WORKER_URL}/admin/mails/${mailId}`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(detailRes.ok()).toBe(true);
|
||||
const mail = await detailRes.json();
|
||||
expect(mail.id).toBe(mailId);
|
||||
expect(mail.address).toBe(address);
|
||||
expect(mail.source).toBe('admin-detail@test.example.com');
|
||||
expect(mail.raw).toContain('Admin Detail Test');
|
||||
expect(mail.raw_blob).toBeUndefined();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('fetch non-existent mail returns null', async ({ request }) => {
|
||||
const res = await request.get(`${WORKER_URL}/admin/mails/99999999`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.json()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_ENV_OFF,
|
||||
WORKER_GZIP_URL,
|
||||
WORKER_URL_SEND_MAIL_DOMAIN,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const addressHeaders = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||
|
||||
async function createAddressAt(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
name: string,
|
||||
domain: string,
|
||||
) {
|
||||
const response = await request.post(`${baseUrl}/api/new_address`, {
|
||||
data: { name: `${name}${Date.now()}`, domain },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function receiveMailAt(
|
||||
request: APIRequestContext,
|
||||
baseUrl: string,
|
||||
address: string,
|
||||
) {
|
||||
const raw = [
|
||||
'From: sender@example.com',
|
||||
`To: ${address}`,
|
||||
`Subject: Split flags ${Date.now()}`,
|
||||
'',
|
||||
'Split flags body',
|
||||
].join('\r\n');
|
||||
const response = await request.post(`${baseUrl}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@example.com', to: address, raw },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function listMails(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
state = 'all',
|
||||
baseUrl = WORKER_URL,
|
||||
) {
|
||||
const response = await request.get(
|
||||
`${baseUrl}/api/mails?limit=100&offset=0&mail_state=${state}`,
|
||||
{ headers: addressHeaders(jwt) },
|
||||
);
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function updateState(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
ids: number[],
|
||||
state: string,
|
||||
) {
|
||||
const response = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: addressHeaders(jwt),
|
||||
data: { ids, state },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async function updateFlagged(
|
||||
request: APIRequestContext,
|
||||
jwt: string,
|
||||
ids: number[],
|
||||
flagged: boolean,
|
||||
) {
|
||||
const response = await request.patch(`${WORKER_URL}/api/mails/flagged`, {
|
||||
headers: addressHeaders(jwt),
|
||||
data: { ids, flagged },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
test.describe('Mail states', () => {
|
||||
test('read status and Flagged switches are independent', async ({ request }) => {
|
||||
test.skip(
|
||||
!WORKER_GZIP_URL || !WORKER_URL_SEND_MAIL_DOMAIN,
|
||||
'Mixed feature workers are not configured',
|
||||
);
|
||||
|
||||
const readOnly = await createAddressAt(
|
||||
request, WORKER_GZIP_URL, 'mail-read-only', 'test.example.com',
|
||||
);
|
||||
const flaggedOnly = await createAddressAt(
|
||||
request, WORKER_URL_SEND_MAIL_DOMAIN, 'mail-flagged-only', 'TEST.EXAMPLE.COM',
|
||||
);
|
||||
|
||||
try {
|
||||
await receiveMailAt(request, WORKER_GZIP_URL, readOnly.address);
|
||||
await receiveMailAt(request, WORKER_URL_SEND_MAIL_DOMAIN, flaggedOnly.address);
|
||||
|
||||
const readSettings = await (await request.get(
|
||||
`${WORKER_GZIP_URL}/open_api/settings`,
|
||||
)).json();
|
||||
expect(readSettings.enableMailReadStatus).toBe(true);
|
||||
expect(readSettings).not.toHaveProperty('enableMailFlagged');
|
||||
|
||||
const flaggedSettings = await (await request.get(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/open_api/settings`,
|
||||
)).json();
|
||||
expect(flaggedSettings).not.toHaveProperty('enableMailReadStatus');
|
||||
expect(flaggedSettings.enableMailFlagged).toBe(true);
|
||||
|
||||
const readList = await request.get(
|
||||
`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`,
|
||||
{ headers: addressHeaders(readOnly.jwt) },
|
||||
);
|
||||
const readMail = (await readList.json()).results[0];
|
||||
expect(readMail.unread).toBe(true);
|
||||
expect(readMail).not.toHaveProperty('flagged');
|
||||
expect((await request.get(`${WORKER_GZIP_URL}/api/mail-states`, {
|
||||
headers: addressHeaders(readOnly.jwt),
|
||||
})).ok()).toBe(true);
|
||||
expect((await request.patch(`${WORKER_GZIP_URL}/api/mails/flagged`, {
|
||||
headers: addressHeaders(readOnly.jwt),
|
||||
data: { ids: [readMail.id], flagged: true },
|
||||
})).status()).toBe(403);
|
||||
|
||||
const flaggedList = await request.get(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails?limit=10&offset=0`,
|
||||
{ headers: addressHeaders(flaggedOnly.jwt) },
|
||||
);
|
||||
const flaggedMail = (await flaggedList.json()).results[0];
|
||||
expect(flaggedMail).not.toHaveProperty('unread');
|
||||
expect(flaggedMail.flagged).toBe(false);
|
||||
expect((await request.get(`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mail-states`, {
|
||||
headers: addressHeaders(flaggedOnly.jwt),
|
||||
})).status()).toBe(403);
|
||||
const addedStar = await request.patch(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/api/mails/flagged`,
|
||||
{
|
||||
headers: addressHeaders(flaggedOnly.jwt),
|
||||
data: { ids: [flaggedMail.id], flagged: true },
|
||||
},
|
||||
);
|
||||
expect((await addedStar.json()).results).toEqual([{ id: flaggedMail.id, flagged: true }]);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
request.delete(`${WORKER_GZIP_URL}/admin/delete_address/${readOnly.address_id}`),
|
||||
request.delete(
|
||||
`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/delete_address/${flaggedOnly.address_id}`,
|
||||
),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('supports unread lifecycle, historical mail, filtering and mailbox isolation', async ({ request }) => {
|
||||
const first = await createTestAddress(request, 'mail-state-first');
|
||||
const second = await createTestAddress(request, 'mail-state-second');
|
||||
|
||||
try {
|
||||
const historical = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
||||
data: { address: first.address, raw: 'Historical mail' },
|
||||
});
|
||||
expect(historical.ok()).toBe(true);
|
||||
|
||||
await seedTestMail(request, first.address, { subject: 'Unread one' });
|
||||
await seedTestMail(request, first.address, { subject: 'Unread two' });
|
||||
await seedTestMail(request, second.address, { subject: 'Other mailbox' });
|
||||
|
||||
const states = await request.get(`${WORKER_URL}/api/mail-states`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
});
|
||||
expect(states.ok()).toBe(true);
|
||||
expect((await states.json()).results.map((state: { value: string }) => state.value))
|
||||
.toEqual(['all', 'unread', 'read']);
|
||||
|
||||
const initial = await listMails(request, first.jwt);
|
||||
expect(initial.count).toBe(3);
|
||||
expect(initial.results.filter((mail: { unread: boolean }) => mail.unread)).toHaveLength(2);
|
||||
expect(initial.results.filter((mail: { unread: boolean }) => !mail.unread)).toHaveLength(1);
|
||||
expect(initial.results.every((mail: { flagged: boolean }) => !mail.flagged)).toBe(true);
|
||||
|
||||
const unreadIds = initial.results
|
||||
.filter((mail: { unread: boolean }) => mail.unread)
|
||||
.map((mail: { id: number }) => mail.id);
|
||||
const denied = await updateState(request, second.jwt, [unreadIds[0]], 'read');
|
||||
expect(denied.changes).toBe(0);
|
||||
const deniedStar = await updateFlagged(request, second.jwt, [unreadIds[0]], true);
|
||||
expect(deniedStar.changes).toBe(0);
|
||||
|
||||
const addedStar = await updateFlagged(request, first.jwt, [unreadIds[0]], true);
|
||||
expect(addedStar.results).toEqual([{ id: unreadIds[0], flagged: true }]);
|
||||
|
||||
const markedRead = await updateState(request, first.jwt, unreadIds, 'read');
|
||||
expect(markedRead.changes).toBe(2);
|
||||
expect(markedRead.results.every((mail: { unread: boolean }) => !mail.unread)).toBe(true);
|
||||
expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(0);
|
||||
expect((await listMails(request, first.jwt, 'read')).results).toHaveLength(3);
|
||||
|
||||
const flagged = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=100&offset=0&mail_state=read&flagged=true`,
|
||||
{ headers: addressHeaders(first.jwt) },
|
||||
);
|
||||
const flaggedMails = (await flagged.json()).results;
|
||||
expect(flaggedMails).toHaveLength(1);
|
||||
expect(flaggedMails[0]).toMatchObject({ id: unreadIds[0], unread: false, flagged: true });
|
||||
|
||||
const markedUnread = await updateState(request, first.jwt, [unreadIds[0]], 'unread');
|
||||
expect(markedUnread.results).toEqual([{ id: unreadIds[0], unread: true }]);
|
||||
expect((await listMails(request, first.jwt, 'unread')).results).toHaveLength(1);
|
||||
|
||||
const detail = await request.get(`${WORKER_URL}/api/mail/${unreadIds[0]}`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
});
|
||||
expect(await detail.json()).toMatchObject({ unread: true, flagged: true });
|
||||
|
||||
await updateFlagged(request, first.jwt, [unreadIds[0]], false);
|
||||
const noFlagged = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=100&offset=0&flagged=true`,
|
||||
{ headers: addressHeaders(first.jwt) },
|
||||
);
|
||||
expect((await noFlagged.json()).results).toHaveLength(0);
|
||||
|
||||
const invalid = await request.patch(`${WORKER_URL}/api/mails/state`, {
|
||||
headers: addressHeaders(first.jwt),
|
||||
data: { ids: [unreadIds[0]], state: 'unknown' },
|
||||
});
|
||||
expect(invalid.status()).toBe(400);
|
||||
} finally {
|
||||
await deleteAddress(request, first.jwt);
|
||||
await deleteAddress(request, second.jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('user APIs only expose and update bound-address mail', async ({ request }) => {
|
||||
let originalSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
const mailboxes: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
|
||||
try {
|
||||
const settings = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
originalSettings = await settings.json();
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: { ...originalSettings, enable: true, enableMailVerify: false, maxAddressCount: 0 },
|
||||
});
|
||||
|
||||
const email = `mail-state-user-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('mail-state-password');
|
||||
expect((await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
})).ok()).toBe(true);
|
||||
const login = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
const { jwt: userJwt } = await login.json();
|
||||
userId = JSON.parse(Buffer.from(userJwt.split('.')[1], 'base64url').toString()).user_id;
|
||||
|
||||
const bound = await createTestAddress(request, 'mail-state-bound');
|
||||
const outsider = await createTestAddress(request, 'mail-state-outsider');
|
||||
mailboxes.push(bound, outsider);
|
||||
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { ...addressHeaders(bound.jwt), 'x-user-token': userJwt },
|
||||
});
|
||||
expect(bind.ok()).toBe(true);
|
||||
|
||||
await seedTestMail(request, bound.address, { subject: 'Bound unread' });
|
||||
await seedTestMail(request, outsider.address, { subject: 'Outsider unread' });
|
||||
|
||||
const userList = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&mail_state=unread`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
const userMails = await userList.json();
|
||||
expect(userMails.results).toHaveLength(1);
|
||||
expect(userMails.results[0].address).toBe(bound.address);
|
||||
|
||||
const outsiderMail = (await listMails(request, outsider.jwt)).results[0];
|
||||
const denied = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [outsiderMail.id], state: 'read' },
|
||||
});
|
||||
expect((await denied.json()).changes).toBe(0);
|
||||
|
||||
const update = await request.patch(`${WORKER_URL}/user_api/mails/state`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [userMails.results[0].id], state: 'read' },
|
||||
});
|
||||
expect((await update.json()).results[0].unread).toBe(false);
|
||||
|
||||
const addStar = await request.patch(`${WORKER_URL}/user_api/mails/flagged`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { ids: [userMails.results[0].id], flagged: true },
|
||||
});
|
||||
expect((await addStar.json()).results[0].flagged).toBe(true);
|
||||
|
||||
const flagged = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&flagged=true`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect((await flagged.json()).results).toHaveLength(1);
|
||||
} finally {
|
||||
await Promise.allSettled(mailboxes.map(mailbox => deleteAddress(request, mailbox.jwt)));
|
||||
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
if (originalSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, { data: originalSettings });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled feature keeps existing responses unchanged', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
const created = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, {
|
||||
data: { name: `mail-state-off-${Date.now()}`, domain: 'test.example.com' },
|
||||
});
|
||||
const mailbox = await created.json();
|
||||
|
||||
try {
|
||||
const raw = [
|
||||
'From: sender@example.com',
|
||||
`To: ${mailbox.address}`,
|
||||
'Subject: States disabled',
|
||||
'',
|
||||
'Disabled body',
|
||||
].join('\r\n');
|
||||
await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@example.com', to: mailbox.address, raw },
|
||||
});
|
||||
|
||||
const list = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
});
|
||||
const body = await list.json();
|
||||
expect(body.results).toHaveLength(1);
|
||||
expect(body.results[0]).not.toHaveProperty('unread');
|
||||
expect(body.results[0]).not.toHaveProperty('flagged');
|
||||
|
||||
const states = await request.get(`${WORKER_URL_ENV_OFF}/api/mail-states`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
});
|
||||
expect(states.status()).toBe(403);
|
||||
const update = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/state`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
data: { ids: [body.results[0].id], state: 'read' },
|
||||
});
|
||||
expect(update.status()).toBe(403);
|
||||
const disabledFlagUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/flagged`, {
|
||||
headers: addressHeaders(mailbox.jwt),
|
||||
data: { ids: [body.results[0].id], flagged: true },
|
||||
});
|
||||
expect(disabledFlagUpdate.status()).toBe(403);
|
||||
} finally {
|
||||
await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${mailbox.address_id}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
|
||||
const MANUAL_BASE_DOMAIN = 'manual.example.com';
|
||||
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${MANUAL_BASE_DOMAIN}`;
|
||||
const RANDOM_SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||
@@ -119,16 +121,16 @@ test.describe('Create Address Subdomain Match', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('persisted false still keeps exact match only', async ({ request }) => {
|
||||
test('random subdomain scope allows a manually entered subdomain', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: SUBDOMAIN },
|
||||
data: { name: uniqueName, domain: RANDOM_SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
expect(res.ok()).toBe(true);
|
||||
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
|
||||
});
|
||||
|
||||
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||
@@ -184,13 +186,43 @@ test.describe('Create Address Subdomain Match', () => {
|
||||
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('deleted random subdomain address can be recreated manually', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const name = `subreuse${Date.now()}`;
|
||||
const firstCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name, domain: TEST_DOMAIN, enableRandomSubdomain: true },
|
||||
});
|
||||
expect(firstCreate.ok()).toBe(true);
|
||||
const firstAddress = await firstCreate.json();
|
||||
const generatedDomain = firstAddress.address.split('@')[1];
|
||||
expect(generatedDomain).not.toBe(TEST_DOMAIN);
|
||||
|
||||
const firstDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${firstAddress.jwt}` },
|
||||
});
|
||||
expect(firstDelete.ok()).toBe(true);
|
||||
|
||||
const secondCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name, domain: generatedDomain },
|
||||
});
|
||||
expect(secondCreate.ok()).toBe(true);
|
||||
const secondAddress = await secondCreate.json();
|
||||
expect(secondAddress.address).toBe(firstAddress.address);
|
||||
|
||||
const secondDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${secondAddress.jwt}` },
|
||||
});
|
||||
expect(secondDelete.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `address-page-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
test.describe('User address pagination', () => {
|
||||
test('paginates addresses and enforces mail ownership', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let outsider: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
const userJwt = user.jwt;
|
||||
userId = user.userId;
|
||||
addresses.push(...await Promise.all([
|
||||
createTestAddress(request, 'user-page-a'),
|
||||
createTestAddress(request, 'user-page-b'),
|
||||
createTestAddress(request, 'user-page-c'),
|
||||
]));
|
||||
outsider = await createTestAddress(request, 'user-page-outsider');
|
||||
|
||||
for (const item of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${item.jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
}
|
||||
|
||||
const defaultPageRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(defaultPageRes.ok()).toBe(true);
|
||||
const defaultPage = await defaultPageRes.json();
|
||||
expect(defaultPage.count).toBe(3);
|
||||
expect(defaultPage.results).toHaveLength(3);
|
||||
|
||||
const firstPageRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=2&offset=0`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(firstPageRes.ok()).toBe(true);
|
||||
const firstPage = await firstPageRes.json();
|
||||
expect(firstPage.count).toBe(3);
|
||||
expect(firstPage.results).toHaveLength(2);
|
||||
expect(firstPage.results[0].mail_count).toBe(0);
|
||||
expect(firstPage.results[0].send_count).toBe(0);
|
||||
expect(firstPage.results[0]).toHaveProperty('source_meta');
|
||||
expect(firstPage.results[0]).not.toHaveProperty('password');
|
||||
|
||||
const secondPageRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=2&offset=2`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(secondPageRes.ok()).toBe(true);
|
||||
const secondPage = await secondPageRes.json();
|
||||
expect(secondPage.count).toBe(0);
|
||||
expect(secondPage.results).toHaveLength(1);
|
||||
|
||||
const invalidLimitRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=101&offset=0`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(invalidLimitRes.status()).toBe(400);
|
||||
|
||||
const invalidOffsetRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=20&offset=-1`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(invalidOffsetRes.status()).toBe(400);
|
||||
|
||||
await seedTestMail(request, addresses[0].address, { subject: 'Bound mail' });
|
||||
await seedTestMail(request, outsider.address, { subject: 'Outsider mail' });
|
||||
|
||||
const userMailsRes = await request.get(`${WORKER_URL}/user_api/mails?limit=20&offset=0`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(userMailsRes.ok()).toBe(true);
|
||||
const userMails = await userMailsRes.json();
|
||||
expect(userMails.count).toBe(1);
|
||||
expect(userMails.results[0].address).toBe(addresses[0].address);
|
||||
|
||||
const filteredOutsiderMailsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(filteredOutsiderMailsRes.ok()).toBe(true);
|
||||
const filteredOutsiderMails = await filteredOutsiderMailsRes.json();
|
||||
expect(filteredOutsiderMails.count).toBe(0);
|
||||
expect(filteredOutsiderMails.results).toHaveLength(0);
|
||||
|
||||
const outsiderMailsRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
});
|
||||
expect(outsiderMailsRes.ok()).toBe(true);
|
||||
const outsiderMails = await outsiderMailsRes.json();
|
||||
expect(outsiderMails.results).toHaveLength(1);
|
||||
|
||||
const forbiddenDeleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/mails/${outsiderMails.results[0].id}`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(forbiddenDeleteRes.ok()).toBe(true);
|
||||
|
||||
const outsiderAfterRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
});
|
||||
expect(outsiderAfterRes.ok()).toBe(true);
|
||||
const outsiderAfter = await outsiderAfterRes.json();
|
||||
expect(outsiderAfter.results).toHaveLength(1);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
[...addresses, outsider].filter((item) => item !== undefined)
|
||||
.map((item) => deleteAddress(request, item.jwt)),
|
||||
);
|
||||
if (userId !== undefined) {
|
||||
const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
expect(deleteUserRes.ok()).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
const restoreSettingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
expect(restoreSettingsRes.ok()).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
deleteAllMailpitMessages,
|
||||
getAddressSender,
|
||||
hashPassword,
|
||||
onMailpitMessage,
|
||||
updateAddressSender,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
async function bindAddress(
|
||||
request: APIRequestContext,
|
||||
userJwt: string,
|
||||
addressJwt: string,
|
||||
) {
|
||||
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${addressJwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('User send mail API', () => {
|
||||
test('sends and manages sent items for a bound address only', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const bound = await createTestAddress(request, 'user-send-bound-');
|
||||
const accessRequest = await createTestAddress(request, 'user-send-access-');
|
||||
const outsider = await createTestAddress(request, 'usr-outsider-');
|
||||
addresses.push(bound, accessRequest, outsider);
|
||||
await bindAddress(request, user.jwt, bound.jwt);
|
||||
await bindAddress(request, user.jwt, accessRequest.jwt);
|
||||
|
||||
const invalidAddressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/0/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(invalidAddressSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderSettingsRes.status()).toBe(400);
|
||||
|
||||
const outsiderCredentialRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderCredentialRes.status()).toBe(400);
|
||||
|
||||
const outsiderAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderAccessRes.status()).toBe(400);
|
||||
|
||||
const outsiderUserSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'Forbidden user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(outsiderUserSendRes.status()).toBe(400);
|
||||
|
||||
const unauthenticatedSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
);
|
||||
expect(unauthenticatedSendboxRes.status()).toBe(401);
|
||||
|
||||
const requestAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(requestAccessRes.ok()).toBe(true);
|
||||
|
||||
const accessSender = await getAddressSender(request, accessRequest.address);
|
||||
await updateAddressSender(request, {
|
||||
address: accessRequest.address,
|
||||
address_id: accessSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const duplicateAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(duplicateAccessRes.status()).toBe(400);
|
||||
expect(await duplicateAccessRes.text()).toContain('Already');
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
const addressSettings = await addressSettingsRes.json();
|
||||
expect(addressSettings.address).toBe(bound.address);
|
||||
expect(addressSettings.send_balance).toBe(10);
|
||||
|
||||
await deleteAllMailpitMessages(request);
|
||||
const subject = `User API send ${Date.now()}`;
|
||||
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||
await listener.ready;
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
from_name: 'User Sender',
|
||||
from_mail: outsider.address,
|
||||
to_name: 'Recipient',
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject,
|
||||
content: 'Sent through the user API',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
const delivered = await listener.message;
|
||||
expect(delivered.From.Address).toBe(bound.address);
|
||||
|
||||
const outsiderSubject = `Outsider send ${Date.now()}`;
|
||||
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: outsiderSubject,
|
||||
content: 'This sent item must remain inaccessible to the user',
|
||||
is_html: false,
|
||||
},
|
||||
});
|
||||
expect(outsiderSendRes.ok()).toBe(true);
|
||||
const outsiderAddressSendboxRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
|
||||
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
|
||||
JSON.parse(item.raw).subject === outsiderSubject
|
||||
));
|
||||
expect(outsiderMail).toBeTruthy();
|
||||
|
||||
const unauthorizedDeleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(unauthorizedDeleteRes.ok()).toBe(true);
|
||||
const outsiderSendboxAfterDeleteRes = await request.get(
|
||||
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||
);
|
||||
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
|
||||
|
||||
const updatedSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
|
||||
|
||||
const sender = await getAddressSender(request, bound.address);
|
||||
await updateAddressSender(request, {
|
||||
address: bound.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
const noBalanceRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||
{
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: 'No balance user send',
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(noBalanceRes.status()).toBe(400);
|
||||
expect(await noBalanceRes.text()).toContain('No balance');
|
||||
|
||||
const userSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(userSendboxRes.ok()).toBe(true);
|
||||
const userSendbox = await userSendboxRes.json();
|
||||
expect(userSendbox.count).toBe(1);
|
||||
expect(userSendbox.results).toHaveLength(1);
|
||||
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
|
||||
|
||||
const filteredSendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(filteredSendboxRes.ok()).toBe(true);
|
||||
expect((await filteredSendboxRes.json()).count).toBe(1);
|
||||
|
||||
const outsiderFilterRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(outsiderFilterRes.ok()).toBe(true);
|
||||
expect((await outsiderFilterRes.json()).count).toBe(0);
|
||||
|
||||
const deleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(deleteRes.ok()).toBe(true);
|
||||
|
||||
const emptySendboxRes = await request.get(
|
||||
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
const emptySendbox = await emptySendboxRes.json();
|
||||
expect(emptySendbox.count).toBe(0);
|
||||
expect(emptySendbox.results).toHaveLength(0);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('applies unlimited balance from the user role access token', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
const userIds: number[] = [];
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userIds.push(user.userId);
|
||||
const address = await createTestAddress(request, 'user-send-role-');
|
||||
addresses.push(address);
|
||||
await bindAddress(request, user.jwt, address.jwt);
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.userId, role_text: 'case-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const accessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(accessRes.ok()).toBe(true);
|
||||
const sender = await getAddressSender(request, address.address);
|
||||
await updateAddressSender(request, {
|
||||
address: address.address,
|
||||
address_id: sender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||
headers: { 'x-user-token': user.jwt },
|
||||
});
|
||||
expect(userSettingsRes.ok()).toBe(true);
|
||||
const { access_token: accessToken } = await userSettingsRes.json();
|
||||
expect(accessToken).toBeTruthy();
|
||||
const userHeaders = {
|
||||
'x-user-token': user.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
|
||||
const addressSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
|
||||
{ headers: userHeaders },
|
||||
);
|
||||
expect(addressSettingsRes.ok()).toBe(true);
|
||||
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
|
||||
|
||||
const sendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
|
||||
{
|
||||
headers: userHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Unlimited role send ${Date.now()}`,
|
||||
content: 'Sent without consuming address balance',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(sendRes.ok()).toBe(true);
|
||||
expect((await getAddressSender(request, address.address)).balance).toBe(0);
|
||||
|
||||
const otherUser = await createUser(request);
|
||||
userIds.push(otherUser.userId);
|
||||
const otherAddress = await createTestAddress(request, 'user-send-other-');
|
||||
addresses.push(otherAddress);
|
||||
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
|
||||
const otherAccessRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
|
||||
{ headers: { 'x-user-token': otherUser.jwt } },
|
||||
);
|
||||
expect(otherAccessRes.ok()).toBe(true);
|
||||
const otherSender = await getAddressSender(request, otherAddress.address);
|
||||
await updateAddressSender(request, {
|
||||
address: otherAddress.address,
|
||||
address_id: otherSender.id,
|
||||
balance: 0,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const mixedHeaders = {
|
||||
'x-user-token': otherUser.jwt,
|
||||
'x-user-access-token': accessToken,
|
||||
};
|
||||
const otherSettingsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
|
||||
{ headers: mixedHeaders },
|
||||
);
|
||||
expect(otherSettingsRes.ok()).toBe(true);
|
||||
expect((await otherSettingsRes.json()).send_balance).toBe(0);
|
||||
|
||||
const otherSendRes = await request.post(
|
||||
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
|
||||
{
|
||||
headers: mixedHeaders,
|
||||
data: {
|
||||
to_mail: 'recipient@test.example.com',
|
||||
subject: `Mismatched role token ${Date.now()}`,
|
||||
content: 'This message must not be sent',
|
||||
is_html: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(otherSendRes.status()).toBe(400);
|
||||
expect(await otherSendRes.text()).toContain('No balance');
|
||||
} finally {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
await Promise.allSettled(userIds.map((userId) => (
|
||||
request.delete(`${WORKER_URL}/admin/users/${userId}`)
|
||||
)));
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FRONTEND_URL, TEST_DOMAIN, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test('create an address with a custom subdomain from the UI', async ({ page, request }) => {
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.getByRole('button', { name: 'Create New Email' }).click();
|
||||
|
||||
const name = `subui${Date.now()}`;
|
||||
const createForm = page.locator('.n-tab-pane:visible form');
|
||||
await createForm.locator('.n-input-group .n-input input').fill(name);
|
||||
|
||||
const domainSelect = createForm.locator('.n-input-group .n-select');
|
||||
await expect(domainSelect.locator('input')).toHaveCount(0);
|
||||
|
||||
const normalSubdomain = createForm.getByRole('radio', { name: 'Normal Domain' });
|
||||
const randomSubdomain = createForm.getByRole('radio', { name: 'Use Random Subdomain' });
|
||||
const customSubdomain = createForm.getByRole('radio', { name: 'Use Custom Subdomain' });
|
||||
|
||||
await expect(normalSubdomain).toBeChecked();
|
||||
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||
await expect(normalSubdomain).not.toBeChecked();
|
||||
await expect(randomSubdomain).toBeChecked();
|
||||
await expect(customSubdomain).not.toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||
await expect(randomSubdomain).not.toBeChecked();
|
||||
await expect(customSubdomain).toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||
await expect(randomSubdomain).toBeChecked();
|
||||
await expect(customSubdomain).not.toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
|
||||
|
||||
await createForm.getByRole('button', { name: 'Create New Email' }).click();
|
||||
|
||||
const domain = `team.${TEST_DOMAIN}`;
|
||||
const address = `tmp${name}@${domain}`;
|
||||
await expect(page.locator('code').getByText(address, { exact: true })).toBeVisible();
|
||||
await page.waitForFunction(() => Boolean(localStorage.getItem('jwt')));
|
||||
jwt = await page.evaluate(() => localStorage.getItem('jwt') || undefined);
|
||||
expect(jwt).toBeTruthy();
|
||||
} finally {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FRONTEND_URL, WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test('persists the selected D1 plan and restores it after reload', async ({ page, request }) => {
|
||||
const seedResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'd1_storage_plan', value: 'free' },
|
||||
});
|
||||
expect(seedResponse.ok()).toBe(true);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'qucickSetup');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
|
||||
const storagePanel = page.locator('.storage-panel');
|
||||
const planSelect = storagePanel.locator('.plan-select .n-select');
|
||||
|
||||
await expect(storagePanel.getByText('Current Database Size', { exact: true })).toBeVisible();
|
||||
await expect(storagePanel.getByText('Database Capacity Limit', { exact: true })).toBeVisible();
|
||||
await expect(storagePanel.getByText('Capacity Usage', { exact: true })).toBeVisible();
|
||||
await expect(planSelect).toContainText('Free');
|
||||
|
||||
await planSelect.click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: 'Workers Paid' }).click();
|
||||
|
||||
await expect(page.getByText('Workers plan saved')).toBeVisible();
|
||||
await expect(storagePanel).toContainText('10.0 GB');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(planSelect).toContainText('Workers Paid');
|
||||
await expect(storagePanel).toContainText('10.0 GB');
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Mail state browser flow', () => {
|
||||
test('does not mark the initial desktop preview as read before a click', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const mailbox = await createTestAddress(request, 'mail-preview-unread');
|
||||
jwt = mailbox.jwt;
|
||||
const subject = `Preview unread ${Date.now()}`;
|
||||
await seedTestMail(request, mailbox.address, { subject });
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate(() => localStorage.setItem('mailListView', 'false'));
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
await expect(page.getByText(subject, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const beforeClick = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await beforeClick.json()).results).toHaveLength(1);
|
||||
|
||||
const readResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByText(subject, { exact: true }).first().click();
|
||||
expect((await readResponse).ok()).toBe(true);
|
||||
|
||||
const afterClick = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await afterClick.json()).results).toHaveLength(0);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('opens, toggles, filters and marks the current page read', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const mailbox = await createTestAddress(request, 'mail-flags-browser');
|
||||
jwt = mailbox.jwt;
|
||||
const subjects = [`Unread A ${Date.now()}`, `Unread B ${Date.now()}`];
|
||||
for (const subject of subjects) {
|
||||
await seedTestMail(request, mailbox.address, { subject });
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
|
||||
for (const subject of subjects) {
|
||||
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
await expect(page.getByText('Unread', { exact: true })).toHaveCount(2);
|
||||
|
||||
const openStateResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByText(subjects[0], { exact: true }).click();
|
||||
expect((await openStateResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toBeVisible();
|
||||
|
||||
const flaggedResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/flagged'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.locator('.mail-content-renderer').getByRole('button', { name: 'Add Star' }).click();
|
||||
expect((await flaggedResponse).ok()).toBe(true);
|
||||
await expect(
|
||||
page.locator('.mail-content-renderer').getByRole('button', { name: 'Remove Star' }),
|
||||
).toBeVisible();
|
||||
|
||||
const unreadAfterOpen = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterOpen.json()).results).toHaveLength(1);
|
||||
|
||||
const toggleResponse = page.waitForResponse((response) => {
|
||||
return new URL(response.url()).pathname === '/api/mails/state'
|
||||
&& response.request().method() === 'PATCH';
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark as Unread' }).click();
|
||||
expect((await toggleResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark as Read' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Back to List' }).click();
|
||||
|
||||
const flaggedFilterResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && url.searchParams.get('flagged') === 'true';
|
||||
});
|
||||
await page.getByRole('checkbox', { name: 'Flagged' }).check();
|
||||
expect((await flaggedFilterResponse).ok()).toBe(true);
|
||||
await expect(page.getByText(subjects[0], { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(subjects[1], { exact: true })).toHaveCount(0);
|
||||
const allMailResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && !url.searchParams.has('flagged');
|
||||
});
|
||||
await page.getByRole('checkbox', { name: 'Flagged' }).uncheck();
|
||||
expect((await allMailResponse).ok()).toBe(true);
|
||||
|
||||
const pageReadResponse = page.waitForResponse((response) => {
|
||||
if (new URL(response.url()).pathname !== '/api/mails/state') return false;
|
||||
if (response.request().method() !== 'PATCH') return false;
|
||||
const body = response.request().postDataJSON();
|
||||
return body.state === 'read' && body.ids.length === 2;
|
||||
});
|
||||
await page.getByRole('button', { name: 'Mark This Page as Read' }).click();
|
||||
expect((await pageReadResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('button', { name: 'Mark This Page as Read' })).toBeHidden();
|
||||
|
||||
const unreadAfterPage = await request.get(
|
||||
`${WORKER_URL}/api/mails?limit=10&offset=0&mail_state=unread`,
|
||||
{ headers: { Authorization: `Bearer ${jwt}` } },
|
||||
);
|
||||
expect((await unreadAfterPage.json()).results).toHaveLength(0);
|
||||
|
||||
const unreadFilterResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/api/mails' && url.searchParams.get('mail_state') === 'unread';
|
||||
});
|
||||
const stateSelect = page.locator('.n-select').filter({ hasText: 'All Mail' }).first();
|
||||
await stateSelect.click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: /^Unread$/ }).click();
|
||||
expect((await unreadFilterResponse).ok()).toBe(true);
|
||||
for (const subject of subjects) {
|
||||
await expect(page.getByText(subject, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
requestSendAccess,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const expectEditorOriginsToAlign = async (page: Page) => {
|
||||
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
|
||||
const textareaElement = editor.querySelector('textarea');
|
||||
const placeholder = editor.querySelector('.n-input__placeholder');
|
||||
if (!textareaElement) throw new Error('compose textarea element not found');
|
||||
if (!placeholder) throw new Error('compose placeholder element not found');
|
||||
const textareaBox = textareaElement.getBoundingClientRect();
|
||||
const placeholderBox = placeholder.getBoundingClientRect();
|
||||
const textareaStyle = getComputedStyle(textareaElement);
|
||||
const placeholderStyle = getComputedStyle(placeholder);
|
||||
return {
|
||||
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
|
||||
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
|
||||
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
|
||||
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
|
||||
};
|
||||
});
|
||||
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
|
||||
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
|
||||
};
|
||||
|
||||
test.describe('Send mail composer', () => {
|
||||
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
|
||||
const api = await apiRequest.newContext();
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
const created = await createTestAddress(api, 'compose-ui');
|
||||
jwt = created.jwt;
|
||||
await requestSendAccess(api, jwt);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.composer-title')).toContainText(created.address);
|
||||
|
||||
const expectedFieldOrder = [
|
||||
'send-mail-sender-address',
|
||||
'send-mail-sender-name',
|
||||
'send-mail-recipient-address',
|
||||
'send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
|
||||
const textarea = page.locator('.compose-textarea textarea');
|
||||
await expectEditorOriginsToAlign(page);
|
||||
|
||||
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
|
||||
const subject = page.getByRole('textbox', { name: /^Subject/ });
|
||||
await recipient.fill('recipient@test.example.com');
|
||||
await subject.fill('Composer preview');
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const htmlContent = [
|
||||
'<h1>Preview heading</h1><p>Preview body</p>',
|
||||
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
|
||||
].join('');
|
||||
let previewDialogAppeared = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
previewDialogAppeared = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await textarea.fill(htmlContent);
|
||||
await page.getByRole('button', { name: 'Preview' }).click();
|
||||
|
||||
const preview = page.locator('.compose-preview');
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText('Preview heading');
|
||||
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
|
||||
expect(previewDialogAppeared).toBe(false);
|
||||
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
await expect(recipient).toHaveValue('recipient@test.example.com');
|
||||
await expect(subject).toHaveValue('Composer preview');
|
||||
await expect(textarea).toHaveValue(htmlContent);
|
||||
|
||||
await page.setViewportSize({ width: 320, height: 800 });
|
||||
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
|
||||
await page.getByText('Enviar correo', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
|
||||
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('HTML', { exact: true }).click();
|
||||
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
|
||||
await expect(previewButton).toBeVisible();
|
||||
const previewBox = await previewButton.boundingBox();
|
||||
expect(previewBox).not.toBeNull();
|
||||
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
|
||||
} finally {
|
||||
try {
|
||||
if (jwt) await deleteAddress(api, jwt);
|
||||
} finally {
|
||||
await api.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'mails');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||
const expectedFieldOrder = [
|
||||
'admin-send-mail-sender-address',
|
||||
'admin-send-mail-sender-name',
|
||||
'admin-send-mail-recipient-address',
|
||||
'admin-send-mail-recipient-name',
|
||||
];
|
||||
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||
(inputs) => inputs.map((input) => input.id)
|
||||
);
|
||||
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||
await expectEditorOriginsToAlign(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function saveUserSettings(request: APIRequestContext, settings: Record<string, unknown>) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/user_settings`, { data: settings });
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `address-browser-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerResponse = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerResponse.ok()).toBe(true);
|
||||
|
||||
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginResponse.ok()).toBe(true);
|
||||
const { jwt } = await loginResponse.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { email, jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
async function bindAddress(request: APIRequestContext, userJwt: string, addressJwt: string) {
|
||||
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${addressJwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('User address pagination browser flow', () => {
|
||||
test('paginates addresses and filters mail', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const request = await apiRequest.newContext();
|
||||
const createdAddresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsResponse = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
originalUserSettings = await settingsResponse.json();
|
||||
await saveUserSettings(request, {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
});
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
|
||||
for (let index = 0; index < 21; index += 1) {
|
||||
const address = await createTestAddress(request, `browser-page-${index}-`);
|
||||
createdAddresses.push(address);
|
||||
await bindAddress(request, user.jwt, address.jwt);
|
||||
}
|
||||
const defaultAddressPageResponse = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(defaultAddressPageResponse.ok()).toBe(true);
|
||||
const defaultAddressPage = await defaultAddressPageResponse.json();
|
||||
expect(defaultAddressPage.count).toBe(21);
|
||||
expect(defaultAddressPage.results).toHaveLength(20);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate((userJwt) => {
|
||||
localStorage.setItem('userJwt', userJwt);
|
||||
}, user.jwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||
const pagination = page.locator('.n-pagination').first();
|
||||
const addressRows = page.locator('.n-data-table-tbody .n-data-table-tr');
|
||||
await expect(pagination).toContainText(/Total:\s*21/);
|
||||
await expect(addressRows).toHaveCount(20);
|
||||
|
||||
await pagination.locator('.n-pagination-item').filter({ hasText: /^2$/ }).click();
|
||||
await expect(addressRows).toHaveCount(1);
|
||||
|
||||
const selectedAddress = createdAddresses[20];
|
||||
|
||||
const initialMailboxAddressesResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/user_api/bind_address'
|
||||
&& url.searchParams.get('limit') === '100';
|
||||
});
|
||||
await page.getByText('Mail Box', { exact: true }).click();
|
||||
const initialMailboxResponse = await initialMailboxAddressesResponse;
|
||||
expect(initialMailboxResponse.ok()).toBe(true);
|
||||
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
|
||||
await mailboxAddressSelect.click();
|
||||
|
||||
const mailboxOptions = page.locator('.n-base-select-menu:visible');
|
||||
await expect(mailboxOptions).toContainText(selectedAddress.address);
|
||||
const filteredMailResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/user_api/mails'
|
||||
&& url.searchParams.get('address') === selectedAddress.address;
|
||||
});
|
||||
await mailboxOptions.getByText(selectedAddress.address, { exact: true }).click();
|
||||
expect((await filteredMailResponse).ok()).toBe(true);
|
||||
} finally {
|
||||
try {
|
||||
try {
|
||||
await Promise.allSettled(createdAddresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await saveUserSettings(request, originalUserSettings);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `user-send-browser-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { email, jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
test.describe('User send mail page', () => {
|
||||
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
|
||||
const request = await apiRequest.newContext();
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let userId: number | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
const address = await createTestAddress(request, 'usr-browser-');
|
||||
const secondAddress = await createTestAddress(request, 'usr-second-');
|
||||
addresses.push(address, secondAddress);
|
||||
for (const boundAddress of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${boundAddress.jwt}`,
|
||||
'x-user-token': user.jwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
}
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate((userJwt) => {
|
||||
localStorage.setItem('userJwt', userJwt);
|
||||
}, user.jwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||
const credentialResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/bind_address_jwt/${address.address_id}`
|
||||
));
|
||||
const addressRow = page.getByRole('row').filter({ hasText: address.address });
|
||||
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
|
||||
expect((await credentialResponse).ok()).toBe(true);
|
||||
await expect(page.getByRole('dialog')).toContainText(address.address);
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const initialSettingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${secondAddress.address_id}/settings`
|
||||
));
|
||||
await page.getByText('Send Mail', { exact: true }).click();
|
||||
expect((await initialSettingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
|
||||
|
||||
const settingsResponse = page.waitForResponse((response) => (
|
||||
new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/settings`
|
||||
));
|
||||
await page.locator('.address-picker-select').click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
|
||||
expect((await settingsResponse).ok()).toBe(true);
|
||||
await expect(page.locator('.address-picker-select')).toContainText(address.address);
|
||||
|
||||
const subject = `Browser user send ${Date.now()}`;
|
||||
await page.getByRole('textbox', { name: /^Recipient address/ })
|
||||
.fill('recipient@test.example.com');
|
||||
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
|
||||
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
|
||||
|
||||
const sendResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname
|
||||
=== `/user_api/address/${address.address_id}/send_mail`
|
||||
));
|
||||
const sendboxResponse = page.waitForResponse((response) => (
|
||||
response.request().method() === 'GET'
|
||||
&& new URL(response.url()).pathname === '/user_api/sendbox'
|
||||
));
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
expect((await sendResponse).ok()).toBe(true);
|
||||
expect((await sendboxResponse).ok()).toBe(true);
|
||||
|
||||
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
|
||||
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
|
||||
.toHaveText(subject, { timeout: 15_000 });
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
}
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.9.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -24,36 +24,36 @@
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@unhead/vue": "^2.1.15",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"@unhead/vue": "^2.1.17",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.16.1",
|
||||
"dompurify": "^3.4.7",
|
||||
"axios": "^1.19.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.4",
|
||||
"naive-ui": "^2.45.0",
|
||||
"postal-mime": "^2.7.6",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.35",
|
||||
"vue": "^3.5.41",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.4.4",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vicons/fa": "^0.13.0",
|
||||
"@vicons/material": "^0.13.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"jsdom": "^28.1.0",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.5",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^3.2.6",
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1",
|
||||
"wrangler": "^4.96.0"
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+1206
-1240
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import axios from 'axios'
|
||||
import i18n from '../i18n'
|
||||
import { getFingerprint } from '../utils/fingerprint'
|
||||
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
||||
import { sanitizeHtml } from '../utils/sanitize-html'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
||||
const {
|
||||
@@ -98,6 +99,8 @@ const getOpenSettings = async (message, notification) => {
|
||||
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
||||
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||
enableMailReadStatus: res["enableMailReadStatus"] === true,
|
||||
enableMailFlagged: res["enableMailFlagged"] === true,
|
||||
enableAutoReply: res["enableAutoReply"] || false,
|
||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||
copyright: res["copyright"] || openSettings.value.copyright,
|
||||
@@ -123,7 +126,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
notification.info({
|
||||
content: () => {
|
||||
return h("div", {
|
||||
innerHTML: announcement.value
|
||||
innerHTML: sanitizeHtml(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`);
|
||||
const { results } = await api.fetch(`/user_api/bind_address?limit=100&offset=0`);
|
||||
for (const row of results || []) {
|
||||
const address = row.address || row.name;
|
||||
if (!address) continue;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { useGlobalState } from '../store'
|
||||
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
|
||||
import {
|
||||
CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound,
|
||||
StarBorderRound, StarRound
|
||||
} from '@vicons/material'
|
||||
import { useIsMobile } from '../utils/composables'
|
||||
import { processItem } from '../utils/email-parser'
|
||||
import { utcToLocalDate } from '../utils';
|
||||
@@ -55,12 +58,40 @@ const props = defineProps({
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
enableMailReadStatus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
enableMailFlagged: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false
|
||||
},
|
||||
updateMailState: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: false
|
||||
},
|
||||
updateMailFlagged: {
|
||||
type: Function,
|
||||
default: () => { },
|
||||
required: false
|
||||
},
|
||||
fetchMailStates: {
|
||||
type: Function,
|
||||
default: () => ({ results: [] }),
|
||||
required: false
|
||||
},
|
||||
})
|
||||
|
||||
const localFilterKeyword = ref('')
|
||||
const mailStateFilter = ref(null)
|
||||
const flaggedOnly = ref(false)
|
||||
const mailStates = ref([])
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
|
||||
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
|
||||
autoRefresh, configAutoRefreshInterval, sendMailModel
|
||||
} = useGlobalState()
|
||||
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
|
||||
@@ -71,6 +102,12 @@ const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const mailListPreviewLineClampValue = computed(() => {
|
||||
const value = Number(mailListPreviewLineClamp.value)
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(5, Math.max(0, Math.round(value)))
|
||||
})
|
||||
|
||||
// Computed property for filtered data (only filter current page)
|
||||
const data = computed(() => {
|
||||
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
|
||||
@@ -88,6 +125,73 @@ const data = computed(() => {
|
||||
});
|
||||
})
|
||||
|
||||
const isMailUnread = (mail) => {
|
||||
return props.enableMailReadStatus && mail?.unread === true
|
||||
}
|
||||
|
||||
const currentPageHasUnread = computed(() => rawData.value.some(isMailUnread))
|
||||
const mailStateFilterOptions = computed(() => mailStates.value.map(state => ({
|
||||
label: state.label || t(state.label_key),
|
||||
value: state.value,
|
||||
})))
|
||||
|
||||
const getReadStateValue = (unread) => {
|
||||
return mailStates.value.find(state => state.unread === unread)?.value
|
||||
}
|
||||
|
||||
const updateUnreadState = async (mails, state) => {
|
||||
if (mails.length === 0 || !state) return true
|
||||
try {
|
||||
const response = await props.updateMailState(mails.map(mail => mail.id), state)
|
||||
const results = response?.results ?? []
|
||||
const resultById = new Map(results.map(result => [result.id, result]))
|
||||
mails.forEach(mail => {
|
||||
const result = resultById.get(mail.id)
|
||||
if (result) mail.unread = result.unread
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const markMailsRead = async (mails) => {
|
||||
return await updateUnreadState(mails.filter(isMailUnread), getReadStateValue(false))
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!curMail.value) return
|
||||
await updateUnreadState([curMail.value], getReadStateValue(!curMail.value.unread))
|
||||
}
|
||||
|
||||
const toggleMailFlagged = async (mail) => {
|
||||
if (!mail) return
|
||||
try {
|
||||
const response = await props.updateMailFlagged([mail.id], !mail.flagged)
|
||||
const result = response?.results?.[0]
|
||||
if (result) mail.flagged = result.flagged
|
||||
if (flaggedOnly.value && !mail.flagged) await backFirstPageAndRefresh()
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailFlagged = async () => {
|
||||
await toggleMailFlagged(curMail.value)
|
||||
}
|
||||
|
||||
const openMail = async (mail) => {
|
||||
curMail.value = mail
|
||||
await markMailsRead([mail])
|
||||
}
|
||||
|
||||
const markCurrentPageRead = async () => {
|
||||
if (!await markMailsRead(rawData.value)) return
|
||||
message.success(t("success"))
|
||||
if (mailStateFilter.value === getReadStateValue(true)) await backFirstPageAndRefresh()
|
||||
}
|
||||
|
||||
const canGoPrevMail = computed(() => {
|
||||
if (!curMail.value) return false
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
@@ -105,12 +209,12 @@ const prevMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
curMail.value = data.value[currentIndex - 1]
|
||||
await openMail(data.value[currentIndex - 1])
|
||||
} else if (page.value > 1) {
|
||||
page.value--
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[data.value.length - 1]
|
||||
await openMail(data.value[data.value.length - 1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,12 +224,12 @@ const nextMail = async () => {
|
||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||
|
||||
if (currentIndex < data.value.length - 1) {
|
||||
curMail.value = data.value[currentIndex + 1]
|
||||
await openMail(data.value[currentIndex + 1])
|
||||
} else if (count.value > page.value * pageSize.value) {
|
||||
page.value++
|
||||
await refresh()
|
||||
if (data.value.length > 0) {
|
||||
curMail.value = data.value[0]
|
||||
await openMail(data.value[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +263,22 @@ const setupAutoRefresh = async (autoRefresh) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadMailStates = async () => {
|
||||
if (!props.enableMailReadStatus) {
|
||||
mailStates.value = []
|
||||
mailStateFilter.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { results = [] } = await props.fetchMailStates()
|
||||
mailStates.value = results
|
||||
mailStateFilter.value = results.find(state => state.default)?.value ?? results[0]?.value ?? null
|
||||
} catch (error) {
|
||||
mailStates.value = []
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
|
||||
watch(autoRefresh, async (autoRefresh, old) => {
|
||||
setupAutoRefresh(autoRefresh)
|
||||
}, { immediate: true })
|
||||
@@ -169,21 +289,34 @@ watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(mailStateFilter, async (_value, oldValue) => {
|
||||
if (oldValue === null) return
|
||||
await backFirstPageAndRefresh()
|
||||
})
|
||||
|
||||
watch(flaggedOnly, async () => {
|
||||
await backFirstPageAndRefresh()
|
||||
})
|
||||
|
||||
watch(() => props.enableMailReadStatus, async (enabled, oldValue) => {
|
||||
if (enabled === oldValue) return
|
||||
await loadMailStates()
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const { results, count: totalCount } = await props.fetchMailData(
|
||||
pageSize.value, (page.value - 1) * pageSize.value
|
||||
pageSize.value, (page.value - 1) * pageSize.value, mailStateFilter.value,
|
||||
flaggedOnly.value
|
||||
);
|
||||
loading.value = true;
|
||||
rawData.value = await Promise.all(results.map(async (item) => {
|
||||
item.checked = false;
|
||||
return await processItem(item);
|
||||
}));
|
||||
if (totalCount > 0) {
|
||||
count.value = totalCount;
|
||||
}
|
||||
if (page.value === 1) count.value = totalCount;
|
||||
curMail.value = null;
|
||||
if (!isMobile.value && data.value.length > 0) {
|
||||
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -202,9 +335,14 @@ const backFirstPageAndRefresh = async () => {
|
||||
const clickRow = async (row) => {
|
||||
if (multiActionMode.value) {
|
||||
row.checked = !row.checked;
|
||||
curMail.value = row;
|
||||
return;
|
||||
}
|
||||
curMail.value = row;
|
||||
if (mailListView.value && curMail.value?.id === row.id) {
|
||||
curMail.value = null;
|
||||
return;
|
||||
}
|
||||
await openMail(row);
|
||||
};
|
||||
|
||||
|
||||
@@ -318,6 +456,7 @@ const multiActionDownload = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMailStates()
|
||||
await refresh();
|
||||
});
|
||||
|
||||
@@ -370,23 +509,45 @@ onBeforeUnmount(() => {
|
||||
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-button v-if="enableMailReadStatus && currentPageHasUnread" @click="markCurrentPageRead" tertiary>
|
||||
{{ t('markCurrentPageRead') }}
|
||||
</n-button>
|
||||
<n-select v-if="enableMailReadStatus" v-model:value="mailStateFilter" :options="mailStateFilterOptions"
|
||||
style="width: 120px" />
|
||||
<n-checkbox v-if="enableMailFlagged" v-model:checked="flaggedOnly">
|
||||
{{ t('flagged') }}
|
||||
</n-checkbox>
|
||||
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
|
||||
clearable />
|
||||
</n-space>
|
||||
</div>
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
|
||||
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange" v-if="!mailListView || curMail">
|
||||
<template #resize-trigger>
|
||||
<div class="split-handle">
|
||||
<div class="split-handle__grip" />
|
||||
</div>
|
||||
</template>
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||
<template #prefix>
|
||||
<n-checkbox v-if="multiActionMode" v-model:checked="row.checked" />
|
||||
<n-button v-else-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
|
||||
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
|
||||
<template #icon>
|
||||
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -412,15 +573,25 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template #2>
|
||||
<div v-if="curMail" style="margin: 8px;">
|
||||
<n-flex justify="space-between">
|
||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevMail') }}
|
||||
</n-button>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-space :wrap="false" align="center">
|
||||
<n-button v-if="mailListView" @click="curMail = null" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('backToList') }}
|
||||
</n-button>
|
||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
@@ -435,6 +606,9 @@ onBeforeUnmount(() => {
|
||||
style="overflow: auto; max-height: 100vh;">
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:enableMailReadStatus="enableMailReadStatus" :enableMailFlagged="enableMailFlagged"
|
||||
:onToggleUnread="toggleCurrentMailUnread"
|
||||
:onToggleFlagged="toggleCurrentMailFlagged"
|
||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||
@@ -446,6 +620,57 @@ onBeforeUnmount(() => {
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
<div v-else class="mail-list-scroll">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="[mailItemClass(row), { 'mail-list-unread': isMailUnread(row) }]">
|
||||
<template #prefix>
|
||||
<n-checkbox v-if="multiActionMode" v-model:checked="row.checked" />
|
||||
<n-button v-else-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
|
||||
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
|
||||
<template #icon>
|
||||
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
|
||||
</template>
|
||||
</n-button>
|
||||
</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 v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<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;">
|
||||
@@ -461,16 +686,38 @@ onBeforeUnmount(() => {
|
||||
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
|
||||
{{ t('refresh') }}
|
||||
</n-button>
|
||||
<n-button v-if="enableMailReadStatus && currentPageHasUnread" @click="markCurrentPageRead" tertiary size="small">
|
||||
{{ t('markCurrentPageRead') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
|
||||
<n-input v-model:value="localFilterKeyword"
|
||||
:placeholder="t('keywordQueryTip')" size="small" clearable />
|
||||
</div>
|
||||
<div v-if="enableMailReadStatus || enableMailFlagged" style="padding: 0 10px; margin-bottom: 10px;">
|
||||
<n-select v-if="enableMailReadStatus" v-model:value="mailStateFilter" :options="mailStateFilterOptions"
|
||||
size="small" />
|
||||
<n-checkbox v-if="enableMailFlagged" v-model:checked="flaggedOnly" style="margin-top: 8px;">
|
||||
{{ t('flagged') }}
|
||||
</n-checkbox>
|
||||
</div>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="{ 'mail-list-unread': isMailUnread(row) }">
|
||||
<template #prefix>
|
||||
<n-button v-if="enableMailFlagged" text circle type="warning" @click.stop="toggleMailFlagged(row)"
|
||||
:aria-label="row.flagged ? t('removeFlagged') : t('addFlagged')">
|
||||
<template #icon>
|
||||
<n-icon :component="row.flagged ? StarRound : StarBorderRound" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
<n-thing :title="row.subject">
|
||||
<template #description>
|
||||
<n-tag v-if="isMailUnread(row)" type="warning">
|
||||
{{ t('unread') }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
@@ -500,6 +747,9 @@ onBeforeUnmount(() => {
|
||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||
:enableMailReadStatus="enableMailReadStatus" :enableMailFlagged="enableMailFlagged"
|
||||
:onToggleUnread="toggleCurrentMailUnread"
|
||||
:onToggleFlagged="toggleCurrentMailFlagged"
|
||||
:onSaveToS3="saveToS3Proxy" />
|
||||
</n-card>
|
||||
</n-drawer-content>
|
||||
@@ -555,8 +805,84 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mail-list-scroll {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 60vh;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.mail-list-thing,
|
||||
.mail-list-title,
|
||||
.mail-list-preview {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-thing,
|
||||
.mail-list-preview {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-thing :deep(.n-thing-main),
|
||||
.mail-list-thing :deep(.n-thing-header),
|
||||
.mail-list-thing :deep(.n-thing-header__title),
|
||||
.mail-list-thing :deep(.n-thing-main__description),
|
||||
.mail-list-thing :deep(.n-thing-main__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-list-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-meta :deep(.n-tag) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-meta-text {
|
||||
max-width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.mail-list-preview {
|
||||
display: -webkit-box;
|
||||
overflow-wrap: anywhere;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mail-list-scroll :deep(.n-list-item__main) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-list-unread :deep(.n-thing-header__title) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.split-handle {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.split-handle__grip {
|
||||
width: 4px;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--n-resize-trigger-color);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.split-handle:hover .split-handle__grip {
|
||||
background-color: var(--n-resize-trigger-color-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound, ImageRound } from '@vicons/material'
|
||||
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { blockRemoteContent } from '../utils/remote-content-policy';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
|
||||
|
||||
const { t } = useScopedI18n('components.MailContentRenderer')
|
||||
|
||||
@@ -33,6 +34,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableMailReadStatus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
enableMailFlagged: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 回调函数 props
|
||||
onDelete: {
|
||||
type: Function,
|
||||
@@ -49,6 +58,14 @@ const props = defineProps({
|
||||
onSaveToS3: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onToggleUnread: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
},
|
||||
onToggleFlagged: {
|
||||
type: Function,
|
||||
default: () => { }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,6 +75,26 @@ const curAttachments = ref([]);
|
||||
const attachmentLoding = ref(false);
|
||||
const showFullscreen = ref(false);
|
||||
|
||||
// Per-mail consent, deliberately independent of the global setting: it only
|
||||
// ever turns true when the user clicks "load images" for this specific mail,
|
||||
// and resets when a different mail is shown.
|
||||
const showRemoteImages = ref(false);
|
||||
watch(() => props.mail.id, () => {
|
||||
showRemoteImages.value = false;
|
||||
});
|
||||
|
||||
const processedMail = computed(() => {
|
||||
if (autoLoadRemoteImages.value || showRemoteImages.value) {
|
||||
return { message: props.mail.message, blocked: 0 };
|
||||
}
|
||||
const { html, blocked } = blockRemoteContent(props.mail.message);
|
||||
return { message: html, blocked };
|
||||
});
|
||||
|
||||
const handleLoadRemoteImages = () => {
|
||||
showRemoteImages.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
props.onDelete();
|
||||
};
|
||||
@@ -125,6 +162,14 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
{{ t('downloadMail') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" @click="onToggleUnread">
|
||||
{{ mail.unread ? t('markRead') : t('markUnread') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="enableMailFlagged" size="small" tertiary type="warning" @click="onToggleFlagged">
|
||||
{{ mail.flagged ? t('removeFlagged') : t('addFlagged') }}
|
||||
</n-button>
|
||||
|
||||
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
|
||||
<template #icon>
|
||||
<n-icon :component="ReplyFilled" />
|
||||
@@ -149,17 +194,32 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
</template>
|
||||
{{ t('fullscreen') }}
|
||||
</n-button>
|
||||
|
||||
</n-space>
|
||||
|
||||
<!-- 外部资源阻断提示 -->
|
||||
<n-alert v-if="processedMail.blocked" type="warning" :show-icon="false" :bordered="false"
|
||||
class="remote-images-banner">
|
||||
<n-space align="center" justify="space-between">
|
||||
<span>{{ t('remoteImagesBlocked', { count: processedMail.blocked }) }}</span>
|
||||
<n-button size="tiny" tertiary type="warning" @click="handleLoadRemoteImages">
|
||||
<template #icon>
|
||||
<n-icon :component="ImageRound" />
|
||||
</template>
|
||||
{{ t('loadRemoteImages') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-alert>
|
||||
|
||||
<!-- AI 提取信息 -->
|
||||
<AiExtractInfo :metadata="mail.metadata" />
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<div class="mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,9 +228,9 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
<n-drawer-content :title="mail.subject" closable>
|
||||
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
@@ -215,6 +275,11 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Let the banner's inner space fill the alert so the button sits on the right. */
|
||||
.remote-images-banner :deep(.n-space) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-content {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
|
||||
@@ -210,8 +210,13 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<n-split direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
|
||||
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
|
||||
<template #resize-trigger>
|
||||
<div class="split-handle">
|
||||
<div class="split-handle__grip" />
|
||||
</div>
|
||||
</template>
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
@@ -379,4 +384,23 @@ pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.split-handle {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.split-handle__grip {
|
||||
width: 4px;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--n-resize-trigger-color);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.split-handle:hover .split-handle__grip {
|
||||
background-color: var(--n-resize-trigger-color-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
export const deMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
|
||||
"views.index.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.index.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.index.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.index.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.index.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
|
||||
"views.admin.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.admin.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.admin.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.admin.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.admin.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Workers-Tarif",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare-Workers-Tarif auswählen",
|
||||
"views.admin.DatabaseManager.planSaved": "Workers-Tarif gespeichert",
|
||||
"views.admin.DatabaseManager.single_database_limit": "Datenbank-Kapazitätslimit",
|
||||
"views.admin.DatabaseManager.storage_description": "Vergleiche die aktuelle Datenbankgröße mit den Tariflimits.",
|
||||
"views.admin.DatabaseManager.storage_tip": "Die Auslastung wird anhand der aktuellen Datenbankgröße und des gewählten Tariflimits berechnet.",
|
||||
"views.admin.DatabaseManager.storage_title": "D1-Speicherkapazität",
|
||||
"views.admin.DatabaseManager.storage_usage": "Kapazitätsauslastung",
|
||||
"views.admin.DatabaseManager.unavailable": "Nicht verfügbar",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} E-Mails",
|
||||
"views.admin.Statistics.activeAddressCount30days": "Aktive Adressanzahl in 30 Tagen",
|
||||
"views.admin.Statistics.activeAddressCount7days": "Aktive Adressanzahl in 7 Tagen",
|
||||
@@ -6,9 +34,9 @@ export const deMessages = {
|
||||
"views.Admin.about": "Über",
|
||||
"views.Header.accessHeader": "Zugangspasswort",
|
||||
"views.Admin.account": "Konto",
|
||||
"views.Index.accountSettings": "Kontoeinstellungen",
|
||||
"views.Index.accountSettings": "E-Mail-Einstellungen",
|
||||
"views.Admin.account_settings": "Kontoeinstellungen",
|
||||
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
|
||||
"views.index.SimpleIndex.accountSettings": "E-Mail-Einstellungen",
|
||||
"views.index.Attachment.action": "Aktion",
|
||||
"views.admin.SenderAccess.action": "Aktion",
|
||||
"views.user.UserSettings.actions": "Aktionen",
|
||||
@@ -60,8 +88,8 @@ export const deMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
|
||||
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
|
||||
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du wirklich dein Konto und alle zugehörigen E-Mails löschen?",
|
||||
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen? Dein Benutzerkonto wird dabei nicht gelöscht.",
|
||||
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
|
||||
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
@@ -74,6 +102,7 @@ export const deMessages = {
|
||||
"components.AiExtractInfo.authLink": "Authentifizierungslink",
|
||||
"views.admin.Maintenance.autoCleanup": "Automatische Bereinigung",
|
||||
"components.MailBox.autoRefresh": "Automatische Aktualisierung",
|
||||
"components.MailBox.backToList": "Zurück zur Liste",
|
||||
"views.common.Appearance.autoRefreshInterval": "Automatisches Aktualisierungsintervall (s)",
|
||||
"views.Index.auto_reply": "Automatische Antwort",
|
||||
"views.index.AutoReply.autoReply": "Automatische Antwort",
|
||||
@@ -97,7 +126,7 @@ export const deMessages = {
|
||||
"views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
|
||||
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
|
||||
"views.index.AccountSettings.changePassword": "Passwort ändern",
|
||||
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
|
||||
"views.admin.UserManagement.changeRole": "Ändern Rolle",
|
||||
"components.SendBox.showCode": "Ansicht des Originalcodes umschalten",
|
||||
"views.admin.Telegram.status": "Status prüfen",
|
||||
@@ -169,7 +198,7 @@ export const deMessages = {
|
||||
"views.admin.UserManagement.delete": "Löschen",
|
||||
"views.admin.AccountSettings.delete_rule": "Löschen",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Löschen",
|
||||
"views.index.AccountSettings.deleteAccount": "Konto löschen",
|
||||
"views.index.AccountSettings.deleteAccount": "E-Mail-Adresse löschen",
|
||||
"views.admin.Account.deleteAccount": "Konto löschen",
|
||||
"views.user.UserSettings.deletePasskey": "Passkey löschen",
|
||||
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
|
||||
@@ -281,7 +310,7 @@ export const deMessages = {
|
||||
"views.user.UserSettings.logout": "Abmelden",
|
||||
"views.user.BindAddress.logout": "Abmelden",
|
||||
"views.Admin.logout": "Abmelden",
|
||||
"views.index.AccountSettings.logout": "Abmelden",
|
||||
"views.index.AccountSettings.logout": "Von aktueller E-Mail-Adresse abmelden",
|
||||
"views.Admin.logoutSuccess": "Erfolgreich abgemeldet",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "E-Mail-Adressfreigabeliste",
|
||||
"views.admin.UserSettings.mailAllowList": "E-Mail-Adressfreigabeliste",
|
||||
@@ -299,7 +328,10 @@ export const deMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "E-Mail erfolgreich gelöscht",
|
||||
"views.user.UserLogin.cannotForgotPassword": "E-Mail-Verifizierung oder Registrierung ist deaktiviert; das Passwort kann nicht zurückgesetzt werden. Bitte den Administrator kontaktieren.",
|
||||
"views.Admin.mailWebhook": "Mail-Webhook",
|
||||
"views.common.Appearance.mailboxSplitSize": "Größe der Mailbox-Aufteilung",
|
||||
"views.common.Appearance.mailboxSplitSize": "Breite der linken Liste in der zweispaltigen Postfachansicht",
|
||||
"views.common.Appearance.mailListView": "Postfach-Listenansicht in voller Breite",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "Zeilen der Textvorschau",
|
||||
"views.common.Appearance.off": "Aus",
|
||||
"views.index.SimpleIndex.refreshSuccess": "E-Mails erfolgreich aktualisiert",
|
||||
"views.Admin.unknow": "E-Mails mit unbekanntem Empfänger",
|
||||
"views.Admin.maintenance": "Wartung",
|
||||
@@ -330,7 +362,8 @@ export const deMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Adressliste ohne Guthabenlimit",
|
||||
"views.index.SimpleIndex.noMails": "Keine E-Mails gefunden",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "In der Systemkonfiguration sind keine Rollen verfügbar",
|
||||
"views.index.SendMail.requestAccessTip": "Noch kein Sendeguthaben vorhanden. Wenn der Administrator ein Standardguthaben aktiviert hat, wird es automatisch zugewiesen; andernfalls Zugriff anfordern oder den Administrator kontaktieren.",
|
||||
"views.index.SendMail.requestAccessTip": "Sendeberechtigung und Guthaben werden für jede E-Mail-Adresse separat verwaltet. Die aktuelle Adresse {address} hat kein verfügbares Guthaben. Fordere die Berechtigung für diese Adresse an oder kontaktiere den Administrator.",
|
||||
"views.index.SendMail.requestSuccess": "Sendeberechtigung für die aktuelle Adresse angefordert",
|
||||
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
|
||||
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
|
||||
@@ -369,6 +402,7 @@ export const deMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
|
||||
"views.admin.CreateAccount.generateName": "Zufälligen Namen erzeugen",
|
||||
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
|
||||
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
|
||||
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
|
||||
@@ -461,7 +495,7 @@ export const deMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "Sendeanzahl",
|
||||
"views.Index.sendmail": "E-Mail senden",
|
||||
"views.Admin.sendMail": "E-Mail senden",
|
||||
"views.index.SendMail.send_balance": "Verbleibendes Sendeguthaben",
|
||||
"views.index.SendMail.send_balance": "Sendeguthaben der aktuellen Adresse",
|
||||
"views.admin.Statistics.sendMailCount": "Anzahl gesendeter E-Mails",
|
||||
"views.admin.AccountSettings.send_mail_limit": "Sende-Limit",
|
||||
"views.user.UserLogin.sendVerificationCode": "Bestätigungscode senden",
|
||||
@@ -522,7 +556,7 @@ export const deMessages = {
|
||||
"views.index.LocalAddress.tip": "Diese Adressen werden in deinem Browser gespeichert und können verloren gehen, wenn du den Browser-Cache leerst.",
|
||||
"views.admin.UserOauth2Settings.tip": "Drittanbieter-Login verwendet automatisch die E-Mail-Adresse des Benutzers zur Registrierung eines Kontos (dieselbe E-Mail gilt als dasselbe Konto). Das Konto entspricht dem regulär registrierten Konto, und das Passwort kann auch über „Passwort vergessen“ gesetzt werden.",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "Dies gilt für alle Sendekanäle. Verwende -1 für unbegrenzt und 0, um das Senden zu blockieren.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "Dies unterscheidet sich von RANDOM_SUBDOMAIN_DOMAINS: Dieser Schalter erlaubt API-Aufrufern, benutzerdefinierte Subdomains direkt anzugeben, während die zufällige Subdomain nur bei der Erstellung automatisch erzeugt wird.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS erlaubt bereits zufällige oder manuelle Subdomains für gelistete Basisdomains. Dieser Schalter erlaubt API-Aufrufern zusätzlich Subdomains unter anderen erlaubten Basisdomains.",
|
||||
"views.index.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
|
||||
"views.admin.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
|
||||
"views.common.Appearance.top": "oben",
|
||||
@@ -578,6 +612,10 @@ export const deMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "Wenn deaktiviert, verarbeitet die KI-Extraktion alle E-Mail-Adressen",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "Wenn aktiviert, verarbeitet die KI-Extraktion nur E-Mails an Adressen auf der Freigabeliste",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
|
||||
"views.admin.CreateAccount.normalSubdomain": "Normale Domain",
|
||||
"views.common.Login.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
|
||||
"views.common.Login.normalSubdomain": "Normale Domain",
|
||||
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
|
||||
"views.admin.AiExtractSettings.allowListTip": "Der Platzhalter * passt auf beliebige Zeichen; z. B. passt *{'@'}example.com auf alle Adressen der Domain example.com",
|
||||
"views.Admin.workerconfig": "Worker-Konfiguration",
|
||||
@@ -613,5 +651,8 @@ export const deMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||
"components.AddressCredentialModal.username": "Benutzername"
|
||||
"components.AddressCredentialModal.username": "Benutzername",
|
||||
"views.User.send_mail": "E-Mail senden",
|
||||
"views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||
"views.user.UserSendBox.sendbox": "Gesendet"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
export const esMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
|
||||
"views.index.SendMail.composeMail": "Redactar correo",
|
||||
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
"views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.index.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.index.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
|
||||
"views.admin.SendMail.composeMail": "Redactar correo",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
"views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Plan de Workers",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Selecciona tu plan de Cloudflare Workers",
|
||||
"views.admin.DatabaseManager.planSaved": "Plan de Workers guardado",
|
||||
"views.admin.DatabaseManager.single_database_limit": "Límite de capacidad de la base de datos",
|
||||
"views.admin.DatabaseManager.storage_description": "Compara el tamaño actual de la base de datos con los límites de tu plan.",
|
||||
"views.admin.DatabaseManager.storage_tip": "El uso se calcula con el tamaño actual de la base de datos y el límite del plan seleccionado.",
|
||||
"views.admin.DatabaseManager.storage_title": "Capacidad de almacenamiento D1",
|
||||
"views.admin.DatabaseManager.storage_usage": "Uso de capacidad",
|
||||
"views.admin.DatabaseManager.unavailable": "No disponible",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} correos",
|
||||
"views.admin.Statistics.activeAddressCount30days": "Cantidad de direcciones activas en 30 días",
|
||||
"views.admin.Statistics.activeAddressCount7days": "Cantidad de direcciones activas en 7 días",
|
||||
@@ -6,9 +34,9 @@ export const esMessages = {
|
||||
"views.Admin.about": "Acerca de",
|
||||
"views.Header.accessHeader": "Contraseña de acceso",
|
||||
"views.Admin.account": "Cuenta",
|
||||
"views.Index.accountSettings": "Configuración de la cuenta",
|
||||
"views.Index.accountSettings": "Configuración del correo",
|
||||
"views.Admin.account_settings": "Configuración de la cuenta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configuración del correo",
|
||||
"views.index.Attachment.action": "Acción",
|
||||
"views.admin.SenderAccess.action": "Acción",
|
||||
"views.user.UserSettings.actions": "Acciones",
|
||||
@@ -60,8 +88,8 @@ export const esMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
|
||||
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
|
||||
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar tu cuenta y todos sus correos?",
|
||||
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección de correo y todos sus correos? Esto no elimina tu cuenta de usuario.",
|
||||
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
|
||||
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
@@ -74,6 +102,7 @@ export const esMessages = {
|
||||
"components.AiExtractInfo.authLink": "Enlace de autenticación",
|
||||
"views.admin.Maintenance.autoCleanup": "Limpieza automática",
|
||||
"components.MailBox.autoRefresh": "Actualización automática",
|
||||
"components.MailBox.backToList": "Volver a la lista",
|
||||
"views.common.Appearance.autoRefreshInterval": "Intervalo de actualización automática (s)",
|
||||
"views.Index.auto_reply": "Respuesta automática",
|
||||
"views.index.AutoReply.autoReply": "Respuesta automática",
|
||||
@@ -97,7 +126,7 @@ export const esMessages = {
|
||||
"views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||
"views.index.AccountSettings.changePassword": "Cambiar contraseña",
|
||||
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
|
||||
"views.admin.UserManagement.changeRole": "Cambiar Rol",
|
||||
"components.SendBox.showCode": "Cambiar vista del código original",
|
||||
"views.admin.Telegram.status": "Ver estado",
|
||||
@@ -169,7 +198,7 @@ export const esMessages = {
|
||||
"views.admin.UserManagement.delete": "Eliminar",
|
||||
"views.admin.AccountSettings.delete_rule": "Eliminar",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
|
||||
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
|
||||
"views.index.AccountSettings.deleteAccount": "Eliminar dirección de correo",
|
||||
"views.admin.Account.deleteAccount": "Eliminar cuenta",
|
||||
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
|
||||
@@ -281,7 +310,7 @@ export const esMessages = {
|
||||
"views.user.UserSettings.logout": "Cerrar sesión",
|
||||
"views.user.BindAddress.logout": "Cerrar sesión",
|
||||
"views.Admin.logout": "Cerrar sesión",
|
||||
"views.index.AccountSettings.logout": "Cerrar sesión",
|
||||
"views.index.AccountSettings.logout": "Cerrar sesión de la dirección de correo actual",
|
||||
"views.Admin.logoutSuccess": "Sesión cerrada correctamente",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "Lista blanca de direcciones de correo",
|
||||
"views.admin.UserSettings.mailAllowList": "Lista blanca de direcciones de correo",
|
||||
@@ -299,7 +328,10 @@ export const esMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "Correo eliminado correctamente",
|
||||
"views.user.UserLogin.cannotForgotPassword": "La verificación por correo o el registro está desactivado; no se puede restablecer la contraseña. Contacta con el administrador.",
|
||||
"views.Admin.mailWebhook": "Webhook de correo",
|
||||
"views.common.Appearance.mailboxSplitSize": "Tamaño de división del buzón",
|
||||
"views.common.Appearance.mailboxSplitSize": "Ancho de la lista izquierda en la vista de buzón de dos columnas",
|
||||
"views.common.Appearance.mailListView": "Vista de lista del buzón de ancho completo",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "Líneas de vista previa del cuerpo",
|
||||
"views.common.Appearance.off": "Desactivado",
|
||||
"views.index.SimpleIndex.refreshSuccess": "Correos actualizados correctamente",
|
||||
"views.Admin.unknow": "Correos con destinatario desconocido",
|
||||
"views.Admin.maintenance": "Mantenimiento",
|
||||
@@ -330,7 +362,8 @@ export const esMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de direcciones sin límite de saldo",
|
||||
"views.index.SimpleIndex.noMails": "No se encontraron correos",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "No hay roles disponibles en la configuración del sistema",
|
||||
"views.index.SendMail.requestAccessTip": "Todavía no hay saldo de envío. Si el administrador activó un saldo por defecto, se asignará automáticamente; si no, solicita acceso o contacta con el administrador.",
|
||||
"views.index.SendMail.requestAccessTip": "El permiso y el saldo de envío se gestionan por separado para cada dirección. La dirección actual, {address}, no tiene saldo disponible. Solicita permiso para esta dirección o contacta con el administrador.",
|
||||
"views.index.SendMail.requestSuccess": "Permiso de envío solicitado para la dirección actual",
|
||||
"components.SendBox.emptySent": "No hay correos enviados",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
|
||||
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
|
||||
@@ -369,6 +402,7 @@ export const esMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
|
||||
"views.admin.CreateAccount.generateName": "Generar nombre aleatorio",
|
||||
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
|
||||
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
|
||||
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
|
||||
@@ -461,7 +495,7 @@ export const esMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "Cantidad enviada",
|
||||
"views.Index.sendmail": "Enviar correo",
|
||||
"views.Admin.sendMail": "Enviar correo",
|
||||
"views.index.SendMail.send_balance": "Saldo restante de envío",
|
||||
"views.index.SendMail.send_balance": "Saldo de envío de la dirección actual",
|
||||
"views.admin.Statistics.sendMailCount": "Cantidad de correos enviados",
|
||||
"views.admin.AccountSettings.send_mail_limit": "Límite de envío",
|
||||
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificación",
|
||||
@@ -522,7 +556,7 @@ export const esMessages = {
|
||||
"views.index.LocalAddress.tip": "Estas direcciones se guardan en tu navegador y podrían perderse si borras la caché.",
|
||||
"views.admin.UserOauth2Settings.tip": "El inicio de sesión de terceros usará automáticamente el correo del usuario para registrar una cuenta (el mismo correo se considera la misma cuenta). También puedes establecer la contraseña con “olvidé mi contraseña”.",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "Se aplica a todos los canales de envío. Usa -1 para ilimitado y 0 para bloquear el envío.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "Esto es diferente de RANDOM_SUBDOMAIN_DOMAINS: este interruptor permite indicar subdominios personalizados; el subdominio aleatorio solo genera uno al crear.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS ya permite subdominios aleatorios o manuales en los dominios base listados. Este interruptor permite además que la API especifique subdominios en otros dominios base permitidos.",
|
||||
"views.index.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
|
||||
"views.admin.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
|
||||
"views.common.Appearance.top": "arriba",
|
||||
@@ -578,6 +612,10 @@ export const esMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "Si está desactivado, la extracción IA procesará todas las direcciones",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "Si está activado, la extracción IA solo procesará correos enviados a direcciones permitidas",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdominio personalizado",
|
||||
"views.admin.CreateAccount.normalSubdomain": "Dominio normal",
|
||||
"views.common.Login.enableCustomSubdomain": "Usar subdominio personalizado",
|
||||
"views.common.Login.normalSubdomain": "Dominio normal",
|
||||
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
|
||||
"views.admin.AiExtractSettings.allowListTip": "El comodín * coincide con cualquier carácter; p. ej., *{'@'}example.com coincide con todas las direcciones del dominio example.com",
|
||||
"views.Admin.workerconfig": "Configuración del Worker",
|
||||
@@ -613,5 +651,8 @@ export const esMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
|
||||
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
|
||||
"components.AddressCredentialModal.username": "Usuario"
|
||||
"components.AddressCredentialModal.username": "Usuario",
|
||||
"views.User.send_mail": "Enviar correo",
|
||||
"views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
|
||||
"views.user.UserSendBox.sendbox": "Enviados"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
export const jaMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
|
||||
"views.index.SendMail.composeMail": "メールを作成",
|
||||
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.index.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.index.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.index.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.index.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
|
||||
"views.admin.SendMail.composeMail": "メールを作成",
|
||||
"views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.admin.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.admin.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.admin.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.admin.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Workers プラン",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare Workers プランを選択",
|
||||
"views.admin.DatabaseManager.planSaved": "Workers プランを保存しました",
|
||||
"views.admin.DatabaseManager.single_database_limit": "データベース容量上限",
|
||||
"views.admin.DatabaseManager.storage_description": "現在のデータベースサイズとプラン上限を比較します。",
|
||||
"views.admin.DatabaseManager.storage_tip": "使用率は現在のデータベースサイズと選択したプラン上限から計算されます。",
|
||||
"views.admin.DatabaseManager.storage_title": "D1 ストレージ容量",
|
||||
"views.admin.DatabaseManager.storage_usage": "容量使用率",
|
||||
"views.admin.DatabaseManager.unavailable": "利用不可",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} 件のメール",
|
||||
"views.admin.Statistics.activeAddressCount30days": "30日間のアクティブなアドレス数",
|
||||
"views.admin.Statistics.activeAddressCount7days": "7日間のアクティブなアドレス数",
|
||||
@@ -6,9 +34,9 @@ export const jaMessages = {
|
||||
"views.Admin.about": "概要",
|
||||
"views.Header.accessHeader": "アクセス用パスワード",
|
||||
"views.Admin.account": "アカウント",
|
||||
"views.Index.accountSettings": "アカウント設定",
|
||||
"views.Index.accountSettings": "メール設定",
|
||||
"views.Admin.account_settings": "アカウント設定",
|
||||
"views.index.SimpleIndex.accountSettings": "アカウント設定",
|
||||
"views.index.SimpleIndex.accountSettings": "メール設定",
|
||||
"views.index.Attachment.action": "操作",
|
||||
"views.admin.SenderAccess.action": "操作",
|
||||
"views.user.UserSettings.actions": "操作",
|
||||
@@ -60,8 +88,8 @@ export const jaMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
|
||||
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
|
||||
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
|
||||
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
|
||||
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
@@ -74,6 +102,7 @@ export const jaMessages = {
|
||||
"components.AiExtractInfo.authLink": "認証リンク",
|
||||
"views.admin.Maintenance.autoCleanup": "自動クリーンアップ",
|
||||
"components.MailBox.autoRefresh": "自動更新",
|
||||
"components.MailBox.backToList": "リストに戻る",
|
||||
"views.common.Appearance.autoRefreshInterval": "自動更新間隔 (秒)",
|
||||
"views.Index.auto_reply": "自動返信",
|
||||
"views.index.AutoReply.autoReply": "自動返信",
|
||||
@@ -97,7 +126,7 @@ export const jaMessages = {
|
||||
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
|
||||
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
|
||||
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
|
||||
"views.index.AccountSettings.changePassword": "パスワードを変更",
|
||||
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
|
||||
"views.admin.UserManagement.changeRole": "ロールを変更",
|
||||
"components.SendBox.showCode": "元のコード表示を切り替え",
|
||||
"views.admin.Telegram.status": "ステータスを確認",
|
||||
@@ -169,7 +198,7 @@ export const jaMessages = {
|
||||
"views.admin.UserManagement.delete": "削除",
|
||||
"views.admin.AccountSettings.delete_rule": "削除",
|
||||
"views.admin.Maintenance.deleteCustomSql": "削除",
|
||||
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
|
||||
"views.index.AccountSettings.deleteAccount": "メールアドレスを削除",
|
||||
"views.admin.Account.deleteAccount": "アカウントを削除",
|
||||
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
|
||||
"views.admin.AccountSettings.delete_success": "削除しました",
|
||||
@@ -281,7 +310,7 @@ export const jaMessages = {
|
||||
"views.user.UserSettings.logout": "ログアウト",
|
||||
"views.user.BindAddress.logout": "ログアウト",
|
||||
"views.Admin.logout": "ログアウト",
|
||||
"views.index.AccountSettings.logout": "ログアウト",
|
||||
"views.index.AccountSettings.logout": "現在のメールアドレスからログアウト",
|
||||
"views.Admin.logoutSuccess": "ログアウトしました",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "メールアドレス許可リスト",
|
||||
"views.admin.UserSettings.mailAllowList": "メールアドレス許可リスト",
|
||||
@@ -299,7 +328,10 @@ export const jaMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "メールを削除しました",
|
||||
"views.user.UserLogin.cannotForgotPassword": "メール検証または登録が無効のため、パスワードを再設定できません。管理者へ連絡してください。",
|
||||
"views.Admin.mailWebhook": "メールWebhook",
|
||||
"views.common.Appearance.mailboxSplitSize": "メールボックス分割サイズ",
|
||||
"views.common.Appearance.mailboxSplitSize": "メールボックス2カラム表示の左側リスト幅",
|
||||
"views.common.Appearance.mailListView": "メールボックス全幅リスト表示",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "本文プレビュー行数",
|
||||
"views.common.Appearance.off": "オフ",
|
||||
"views.index.SimpleIndex.refreshSuccess": "メールを更新しました",
|
||||
"views.Admin.unknow": "受信者不明のメール",
|
||||
"views.Admin.maintenance": "メンテナンス",
|
||||
@@ -330,7 +362,8 @@ export const jaMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
|
||||
"views.index.SimpleIndex.noMails": "メールが見つかりません",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
|
||||
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者へ連絡してください。",
|
||||
"views.index.SendMail.requestAccessTip": "送信権限と残高はメールアドレスごとに管理されます。現在のアドレス {address} には利用可能な残高がありません。このアドレスの権限を申請するか、管理者に連絡してください。",
|
||||
"views.index.SendMail.requestSuccess": "現在のアドレスの送信権限を申請しました",
|
||||
"components.SendBox.emptySent": "送信済みメールはありません",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
|
||||
"views.Admin.userOauth2Settings": "OAuth2設定",
|
||||
@@ -369,6 +402,7 @@ export const jaMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
|
||||
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
|
||||
"views.admin.CreateAccount.generateName": "ランダム名を生成",
|
||||
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
|
||||
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
|
||||
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
|
||||
@@ -461,7 +495,7 @@ export const jaMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "送信数",
|
||||
"views.Index.sendmail": "メール送信",
|
||||
"views.Admin.sendMail": "メール送信",
|
||||
"views.index.SendMail.send_balance": "残り送信枠",
|
||||
"views.index.SendMail.send_balance": "現在のアドレスの送信残高",
|
||||
"views.admin.Statistics.sendMailCount": "送信メール数",
|
||||
"views.admin.AccountSettings.send_mail_limit": "送信上限",
|
||||
"views.user.UserLogin.sendVerificationCode": "認証コードを送信",
|
||||
@@ -522,7 +556,7 @@ export const jaMessages = {
|
||||
"views.index.LocalAddress.tip": "これらのアドレスはブラウザに保存されており、キャッシュを消すと失われる可能性があります。",
|
||||
"views.admin.UserOauth2Settings.tip": "サードパーティログインではユーザーのメールアドレスで自動的にアカウント登録されます(同じメールは同一アカウントとして扱われます)。「パスワードを忘れた」からパスワード設定も可能です。",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "すべての送信チャネルに適用されます。-1 は無制限、0 は送信禁止です。",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "これは RANDOM_SUBDOMAIN_DOMAINS と異なります。この設定では API 呼び出し側が独自サブドメインを指定でき、ランダムサブドメインは作成時に自動生成されるだけです。",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS に列挙したベースドメインでは、ランダムまたは手動のサブドメインを利用できます。この設定は、他の許可済みベースドメインでも API からサブドメインを指定できるようにします。",
|
||||
"views.index.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
|
||||
"views.admin.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
|
||||
"views.common.Appearance.top": "上",
|
||||
@@ -578,6 +612,10 @@ export const jaMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "カスタムサブドメインを使用",
|
||||
"views.admin.CreateAccount.normalSubdomain": "通常ドメイン",
|
||||
"views.common.Login.enableCustomSubdomain": "カスタムサブドメインを使用",
|
||||
"views.common.Login.normalSubdomain": "通常ドメイン",
|
||||
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
|
||||
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
|
||||
"views.Admin.workerconfig": "Worker設定",
|
||||
@@ -613,5 +651,8 @@ export const jaMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||
"components.AddressCredentialModal.username": "ユーザー名"
|
||||
"components.AddressCredentialModal.username": "ユーザー名",
|
||||
"views.User.send_mail": "メール送信",
|
||||
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||
"views.user.UserSendBox.sendbox": "送信済み"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,32 @@
|
||||
export const ptBRMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
|
||||
"views.index.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
"views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.index.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.index.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
|
||||
"views.admin.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
"views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.admin.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Plano do Workers",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Selecione seu plano do Cloudflare Workers",
|
||||
"views.admin.DatabaseManager.planSaved": "Plano do Workers salvo",
|
||||
"views.admin.DatabaseManager.single_database_limit": "Limite de capacidade do banco de dados",
|
||||
"views.admin.DatabaseManager.storage_description": "Compare o tamanho atual do banco de dados com os limites do seu plano.",
|
||||
"views.admin.DatabaseManager.storage_tip": "O uso é calculado com o tamanho atual do banco de dados e o limite do plano selecionado.",
|
||||
"views.admin.DatabaseManager.storage_title": "Capacidade de armazenamento D1",
|
||||
"views.admin.DatabaseManager.storage_usage": "Uso da capacidade",
|
||||
"views.admin.DatabaseManager.unavailable": "Indisponível",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} e-mails",
|
||||
"views.admin.Statistics.activeAddressCount30days": "Quantidade de endereços ativos em 30 dias",
|
||||
"views.admin.Statistics.activeAddressCount7days": "Quantidade de endereços ativos em 7 dias",
|
||||
@@ -6,9 +34,9 @@ export const ptBRMessages = {
|
||||
"views.Admin.about": "Sobre",
|
||||
"views.Header.accessHeader": "Senha de acesso",
|
||||
"views.Admin.account": "Conta",
|
||||
"views.Index.accountSettings": "Configurações da conta",
|
||||
"views.Index.accountSettings": "Configurações de e-mail",
|
||||
"views.Admin.account_settings": "Configurações da conta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configurações de e-mail",
|
||||
"views.index.Attachment.action": "Ação",
|
||||
"views.admin.SenderAccess.action": "Ação",
|
||||
"views.user.UserSettings.actions": "Ações",
|
||||
@@ -60,8 +88,8 @@ export const ptBRMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
|
||||
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
|
||||
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir sua conta e todos os e-mails dela?",
|
||||
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço de e-mail e todas as mensagens dele? Isso não exclui sua conta de usuário.",
|
||||
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
|
||||
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
@@ -74,6 +102,7 @@ export const ptBRMessages = {
|
||||
"components.AiExtractInfo.authLink": "Link de autenticação",
|
||||
"views.admin.Maintenance.autoCleanup": "Limpeza automática",
|
||||
"components.MailBox.autoRefresh": "Atualização automática",
|
||||
"components.MailBox.backToList": "Voltar para a lista",
|
||||
"views.common.Appearance.autoRefreshInterval": "Intervalo de atualização automática (s)",
|
||||
"views.Index.auto_reply": "Resposta automática",
|
||||
"views.index.AutoReply.autoReply": "Resposta automática",
|
||||
@@ -97,7 +126,7 @@ export const ptBRMessages = {
|
||||
"views.user.AddressManagement.changeMailAddress": "Alterar endereço",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||
"views.index.AccountSettings.changePassword": "Alterar senha",
|
||||
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
|
||||
"views.admin.UserManagement.changeRole": "Alterar Função",
|
||||
"components.SendBox.showCode": "Alternar visualização do código original",
|
||||
"views.admin.Telegram.status": "Ver status",
|
||||
@@ -169,7 +198,7 @@ export const ptBRMessages = {
|
||||
"views.admin.UserManagement.delete": "Excluir",
|
||||
"views.admin.AccountSettings.delete_rule": "Excluir",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Excluir",
|
||||
"views.index.AccountSettings.deleteAccount": "Excluir conta",
|
||||
"views.index.AccountSettings.deleteAccount": "Excluir endereço de e-mail",
|
||||
"views.admin.Account.deleteAccount": "Excluir conta",
|
||||
"views.user.UserSettings.deletePasskey": "Excluir passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
|
||||
@@ -281,7 +310,7 @@ export const ptBRMessages = {
|
||||
"views.user.UserSettings.logout": "Sair",
|
||||
"views.user.BindAddress.logout": "Sair",
|
||||
"views.Admin.logout": "Sair",
|
||||
"views.index.AccountSettings.logout": "Sair",
|
||||
"views.index.AccountSettings.logout": "Sair do endereço de e-mail atual",
|
||||
"views.Admin.logoutSuccess": "Logout realizado com sucesso",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "Lista branca de endereços de e-mail",
|
||||
"views.admin.UserSettings.mailAllowList": "Lista branca de endereços de e-mail",
|
||||
@@ -299,7 +328,10 @@ export const ptBRMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "E-mail excluído com sucesso",
|
||||
"views.user.UserLogin.cannotForgotPassword": "A verificação por e-mail ou o registro está desativado; não é possível redefinir a senha. Entre em contato com o administrador.",
|
||||
"views.Admin.mailWebhook": "Webhook de e-mail",
|
||||
"views.common.Appearance.mailboxSplitSize": "Tamanho da divisão da caixa de correio",
|
||||
"views.common.Appearance.mailboxSplitSize": "Largura da lista esquerda na visualização de duas colunas da caixa de correio",
|
||||
"views.common.Appearance.mailListView": "Visualização de lista da caixa de correio em largura total",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "Linhas da prévia do corpo",
|
||||
"views.common.Appearance.off": "Desativado",
|
||||
"views.index.SimpleIndex.refreshSuccess": "E-mails atualizados com sucesso",
|
||||
"views.Admin.unknow": "E-mails com destinatário desconhecido",
|
||||
"views.Admin.maintenance": "Manutenção",
|
||||
@@ -330,7 +362,8 @@ export const ptBRMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de endereços sem limite de saldo",
|
||||
"views.index.SimpleIndex.noMails": "Nenhum e-mail encontrado",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "Nenhuma função disponível na configuração do sistema",
|
||||
"views.index.SendMail.requestAccessTip": "Ainda não há saldo de envio. Se o administrador ativou um saldo padrão, ele será atribuído automaticamente; caso contrário, solicite acesso ou fale com o administrador.",
|
||||
"views.index.SendMail.requestAccessTip": "A permissão e o saldo de envio são gerenciados separadamente para cada endereço. O endereço atual, {address}, não tem saldo disponível. Solicite permissão para este endereço ou fale com o administrador.",
|
||||
"views.index.SendMail.requestSuccess": "Permissão de envio solicitada para o endereço atual",
|
||||
"components.SendBox.emptySent": "Nenhum e-mail enviado",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "Não configurado (usar configurações globais)",
|
||||
"views.Admin.userOauth2Settings": "Configurações de OAuth2",
|
||||
@@ -369,6 +402,7 @@ export const ptBRMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
|
||||
"views.admin.CreateAccount.generateName": "Gerar nome aleatório",
|
||||
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
|
||||
"views.user.UserLogin.pleaseInputCode": "Informe o código",
|
||||
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
|
||||
@@ -461,7 +495,7 @@ export const ptBRMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "Quantidade enviada",
|
||||
"views.Index.sendmail": "Enviar e-mail",
|
||||
"views.Admin.sendMail": "Enviar e-mail",
|
||||
"views.index.SendMail.send_balance": "Saldo restante de envio",
|
||||
"views.index.SendMail.send_balance": "Saldo de envio do endereço atual",
|
||||
"views.admin.Statistics.sendMailCount": "Quantidade de e-mails enviados",
|
||||
"views.admin.AccountSettings.send_mail_limit": "Limite de envio",
|
||||
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificação",
|
||||
@@ -522,7 +556,7 @@ export const ptBRMessages = {
|
||||
"views.index.LocalAddress.tip": "Esses endereços ficam armazenados no navegador e podem ser perdidos se você limpar o cache.",
|
||||
"views.admin.UserOauth2Settings.tip": "O login de terceiros usará automaticamente o e-mail do usuário para registrar uma conta (o mesmo e-mail será considerado a mesma conta). Você também pode definir a senha via “esqueci minha senha”.",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "Aplica-se a todos os canais de envio. Use -1 para ilimitado e 0 para bloquear o envio.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "Isso é diferente de RANDOM_SUBDOMAIN_DOMAINS: esta opção permite informar subdomínios personalizados; o subdomínio aleatório apenas gera um durante a criação.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS já permite subdomínios aleatórios ou manuais nos domínios base listados. Esta opção também permite que a API informe subdomínios em outros domínios base permitidos.",
|
||||
"views.index.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
|
||||
"views.admin.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
|
||||
"views.common.Appearance.top": "topo",
|
||||
@@ -578,6 +612,10 @@ export const ptBRMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "Quando desativado, a extração por IA processará todos os endereços",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "Quando ativado, a extração por IA só processará e-mails enviados aos endereços permitidos",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdomínio personalizado",
|
||||
"views.admin.CreateAccount.normalSubdomain": "Domínio normal",
|
||||
"views.common.Login.enableCustomSubdomain": "Usar subdomínio personalizado",
|
||||
"views.common.Login.normalSubdomain": "Domínio normal",
|
||||
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
|
||||
"views.admin.AiExtractSettings.allowListTip": "O curinga * corresponde a quaisquer caracteres; ex.: *{'@'}example.com corresponde a todos os endereços do domínio example.com",
|
||||
"views.Admin.workerconfig": "Configuração do Worker",
|
||||
@@ -613,5 +651,8 @@ export const ptBRMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
|
||||
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
|
||||
"components.AddressCredentialModal.username": "Nome de usuário"
|
||||
"components.AddressCredentialModal.username": "Nome de usuário",
|
||||
"views.User.send_mail": "Enviar e-mail",
|
||||
"views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
|
||||
"views.user.UserSendBox.sendbox": "Enviados"
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"components.MailBox": {
|
||||
"allMail": {
|
||||
"en": "All Mail",
|
||||
"zh": "全部邮件"
|
||||
},
|
||||
"attachments": {
|
||||
"en": "Show Attachments",
|
||||
"zh": "查看附件"
|
||||
@@ -42,6 +46,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Auto Refresh",
|
||||
"zh": "自动刷新"
|
||||
},
|
||||
"backToList": {
|
||||
"en": "Back to List",
|
||||
"zh": "返回列表"
|
||||
},
|
||||
"cancelMultiAction": {
|
||||
"en": "Cancel Multi Action",
|
||||
"zh": "取消多选"
|
||||
@@ -66,10 +74,18 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Forward",
|
||||
"zh": "转发"
|
||||
},
|
||||
"flagged": {
|
||||
"en": "Flagged",
|
||||
"zh": "星标邮件"
|
||||
},
|
||||
"keywordQueryTip": {
|
||||
"en": "Filter current page",
|
||||
"zh": "过滤当前页"
|
||||
},
|
||||
"markCurrentPageRead": {
|
||||
"en": "Mark This Page as Read",
|
||||
"zh": "本页全部已读"
|
||||
},
|
||||
"multiAction": {
|
||||
"en": "Multi Action",
|
||||
"zh": "多选"
|
||||
@@ -90,6 +106,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Query",
|
||||
"zh": "查询"
|
||||
},
|
||||
"read": {
|
||||
"en": "Read",
|
||||
"zh": "已读"
|
||||
},
|
||||
"refresh": {
|
||||
"en": "Refresh",
|
||||
"zh": "刷新"
|
||||
@@ -125,6 +145,18 @@ export const MESSAGE_REGISTRY = {
|
||||
"unselectAll": {
|
||||
"en": "Unselect All",
|
||||
"zh": "取消全选"
|
||||
},
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
"zh": "未读"
|
||||
},
|
||||
"addFlagged": {
|
||||
"en": "Add Star",
|
||||
"zh": "添加星标"
|
||||
},
|
||||
"removeFlagged": {
|
||||
"en": "Remove Star",
|
||||
"zh": "取消星标"
|
||||
}
|
||||
},
|
||||
"components.AiExtractInfo": {
|
||||
@@ -166,6 +198,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "View Attachments",
|
||||
"zh": "查看附件"
|
||||
},
|
||||
"addFlagged": {
|
||||
"en": "Add Star",
|
||||
"zh": "添加星标"
|
||||
},
|
||||
"delete": {
|
||||
"en": "Delete",
|
||||
"zh": "删除"
|
||||
@@ -186,6 +222,26 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Fullscreen",
|
||||
"zh": "全屏"
|
||||
},
|
||||
"loadRemoteImages": {
|
||||
"en": "Load Images",
|
||||
"zh": "加载图片"
|
||||
},
|
||||
"markRead": {
|
||||
"en": "Mark as Read",
|
||||
"zh": "标记已读"
|
||||
},
|
||||
"markUnread": {
|
||||
"en": "Mark as Unread",
|
||||
"zh": "标记未读"
|
||||
},
|
||||
"remoteImagesBlocked": {
|
||||
"en": "{count} remote resources blocked to protect your privacy",
|
||||
"zh": "已阻止 {count} 项外部资源以保护隐私"
|
||||
},
|
||||
"removeFlagged": {
|
||||
"en": "Remove Star",
|
||||
"zh": "取消星标"
|
||||
},
|
||||
"reply": {
|
||||
"en": "Reply",
|
||||
"zh": "回复"
|
||||
@@ -407,8 +463,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "关于"
|
||||
},
|
||||
"accountSettings": {
|
||||
"en": "Account Settings",
|
||||
"zh": "账户"
|
||||
"en": "Email Settings",
|
||||
"zh": "邮箱设置"
|
||||
},
|
||||
"appearance": {
|
||||
"en": "Appearance",
|
||||
@@ -726,6 +782,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Bind Mail Address",
|
||||
"zh": "绑定邮箱地址"
|
||||
},
|
||||
"send_mail": {
|
||||
"en": "Send Mail",
|
||||
"zh": "发送邮件"
|
||||
},
|
||||
"user_mail_box_tab": {
|
||||
"en": "Mail Box",
|
||||
"zh": "收件箱"
|
||||
@@ -735,6 +795,16 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "用户设置"
|
||||
}
|
||||
},
|
||||
"views.user.UserSendBox": {
|
||||
"noAddress": {
|
||||
"en": "Select a bound email address to continue",
|
||||
"zh": "请选择一个已绑定的邮箱地址"
|
||||
},
|
||||
"sendbox": {
|
||||
"en": "Sent",
|
||||
"zh": "发件箱"
|
||||
}
|
||||
},
|
||||
"views.user.UserLogin": {
|
||||
"cannotForgotPassword": {
|
||||
"en": "Mail verification is disabled or register is disabled, cannot reset password, please contact administrator",
|
||||
@@ -840,6 +910,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Mail Count",
|
||||
"zh": "邮件数量"
|
||||
},
|
||||
"itemCount": {
|
||||
"en": "Total",
|
||||
"zh": "总数"
|
||||
},
|
||||
"name": {
|
||||
"en": "Name",
|
||||
"zh": "名称"
|
||||
@@ -875,8 +949,8 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"views.index.AccountSettings": {
|
||||
"changePassword": {
|
||||
"en": "Change Password",
|
||||
"zh": "修改密码"
|
||||
"en": "Change Address Password",
|
||||
"zh": "修改地址密码"
|
||||
},
|
||||
"clearInbox": {
|
||||
"en": "Clear Inbox",
|
||||
@@ -899,20 +973,20 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "确认密码"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"en": "Delete Account",
|
||||
"zh": "删除账户"
|
||||
"en": "Delete Email Address",
|
||||
"zh": "删除邮箱地址"
|
||||
},
|
||||
"deleteAccountConfirm": {
|
||||
"en": "Are you sure to delete your account and all emails for this account?",
|
||||
"zh": "确定要删除你的账户和其中的所有邮件吗?"
|
||||
"en": "Are you sure you want to delete this email address and all of its emails? This does not delete your user account.",
|
||||
"zh": "确定要删除当前邮箱地址及其中的所有邮件吗?此操作不会删除你的用户账号。"
|
||||
},
|
||||
"logout": {
|
||||
"en": "Logout",
|
||||
"zh": "退出登录"
|
||||
"en": "Log Out of Address",
|
||||
"zh": "退出地址登录"
|
||||
},
|
||||
"logoutConfirm": {
|
||||
"en": "Are you sure to logout?",
|
||||
"zh": "确定要退出登录吗?"
|
||||
"en": "Are you sure you want to log out of the current email address?",
|
||||
"zh": "确定要退出当前邮箱地址的登录吗?"
|
||||
},
|
||||
"newPassword": {
|
||||
"en": "New Password",
|
||||
@@ -1010,6 +1084,14 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.index.SendMail": {
|
||||
"balanceUnavailable": {
|
||||
"en": "No send balance for this address",
|
||||
"zh": "当前地址暂无发信额度"
|
||||
},
|
||||
"composeMail": {
|
||||
"en": "Compose email",
|
||||
"zh": "写邮件"
|
||||
},
|
||||
"content": {
|
||||
"en": "Content",
|
||||
"zh": "内容"
|
||||
@@ -1018,6 +1100,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Content is empty",
|
||||
"zh": "内容不能为空"
|
||||
},
|
||||
"contentPlaceholder": {
|
||||
"en": "Write your message...",
|
||||
"zh": "输入邮件正文..."
|
||||
},
|
||||
"draftSaved": {
|
||||
"en": "Draft saved in this browser",
|
||||
"zh": "草稿已保存在当前浏览器"
|
||||
},
|
||||
"edit": {
|
||||
"en": "Edit",
|
||||
"zh": "编辑"
|
||||
@@ -1039,12 +1129,24 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "预览"
|
||||
},
|
||||
"requestAccess": {
|
||||
"en": "Request Access",
|
||||
"zh": "申请权限"
|
||||
"en": "Request send access",
|
||||
"zh": "申请发信权限"
|
||||
},
|
||||
"requestAccessTip": {
|
||||
"en": "No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.",
|
||||
"zh": "当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。"
|
||||
"en": "Send permission and balance are managed separately for each email address. The current address, {address}, has no available balance. Request permission for this address or contact the admin.",
|
||||
"zh": "发信权限和额度按邮箱地址独立管理。当前地址 {address} 暂无可用额度,请为此地址申请发信权限或联系管理员。"
|
||||
},
|
||||
"requestSuccess": {
|
||||
"en": "Send permission requested for the current address",
|
||||
"zh": "已为当前地址提交发信权限申请"
|
||||
},
|
||||
"recipientAddress": {
|
||||
"en": "Recipient address",
|
||||
"zh": "收件人邮箱"
|
||||
},
|
||||
"recipientName": {
|
||||
"en": "Recipient name (optional)",
|
||||
"zh": "收件人名称(可选)"
|
||||
},
|
||||
"rich text": {
|
||||
"en": "Rich Text",
|
||||
@@ -1055,8 +1157,16 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "发送"
|
||||
},
|
||||
"send_balance": {
|
||||
"en": "Send Mail Balance Left",
|
||||
"zh": "剩余发送邮件额度"
|
||||
"en": "Current Address Send Balance",
|
||||
"zh": "当前地址剩余发信额度"
|
||||
},
|
||||
"senderAddress": {
|
||||
"en": "Sender address",
|
||||
"zh": "发件邮箱"
|
||||
},
|
||||
"senderName": {
|
||||
"en": "Sender name (optional)",
|
||||
"zh": "发件人名称(可选)"
|
||||
},
|
||||
"subject": {
|
||||
"en": "Subject",
|
||||
@@ -1072,7 +1182,7 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"text": {
|
||||
"en": "Text",
|
||||
"zh": "文本"
|
||||
"zh": "纯文本"
|
||||
},
|
||||
"toMailEmpty": {
|
||||
"en": "Recipient address is empty",
|
||||
@@ -1089,8 +1199,8 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"views.index.SimpleIndex": {
|
||||
"accountSettings": {
|
||||
"en": "Account Settings",
|
||||
"zh": "账户设置"
|
||||
"en": "Email Settings",
|
||||
"zh": "邮箱设置"
|
||||
},
|
||||
"addressCopied": {
|
||||
"en": "Address copied successfully",
|
||||
@@ -1258,6 +1368,14 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.admin.SendMail": {
|
||||
"adminComposeTip": {
|
||||
"en": "Send from a configured email address",
|
||||
"zh": "使用已配置的邮箱地址发信"
|
||||
},
|
||||
"composeMail": {
|
||||
"en": "Compose email",
|
||||
"zh": "写邮件"
|
||||
},
|
||||
"content": {
|
||||
"en": "Content",
|
||||
"zh": "内容"
|
||||
@@ -1266,6 +1384,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Content is empty",
|
||||
"zh": "内容不能为空"
|
||||
},
|
||||
"contentPlaceholder": {
|
||||
"en": "Write your message...",
|
||||
"zh": "输入邮件正文..."
|
||||
},
|
||||
"draftSaved": {
|
||||
"en": "Draft saved in this browser",
|
||||
"zh": "草稿已保存在当前浏览器"
|
||||
},
|
||||
"edit": {
|
||||
"en": "Edit",
|
||||
"zh": "编辑"
|
||||
@@ -1290,6 +1416,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Preview",
|
||||
"zh": "预览"
|
||||
},
|
||||
"recipientAddress": {
|
||||
"en": "Recipient address",
|
||||
"zh": "收件人邮箱"
|
||||
},
|
||||
"recipientName": {
|
||||
"en": "Recipient name (optional)",
|
||||
"zh": "收件人名称(可选)"
|
||||
},
|
||||
"rich text": {
|
||||
"en": "Rich Text",
|
||||
"zh": "富文本"
|
||||
@@ -1298,6 +1432,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Send",
|
||||
"zh": "发送"
|
||||
},
|
||||
"senderAddress": {
|
||||
"en": "Sender address",
|
||||
"zh": "发件邮箱"
|
||||
},
|
||||
"senderName": {
|
||||
"en": "Sender name (optional)",
|
||||
"zh": "发件人名称(可选)"
|
||||
},
|
||||
"subject": {
|
||||
"en": "Subject",
|
||||
"zh": "主题"
|
||||
@@ -1312,7 +1454,7 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"text": {
|
||||
"en": "Text",
|
||||
"zh": "文本"
|
||||
"zh": "纯文本"
|
||||
},
|
||||
"toMailEmpty": {
|
||||
"en": "Recipient address is empty",
|
||||
@@ -1674,6 +1816,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Current DB Version",
|
||||
"zh": "当前数据库版本"
|
||||
},
|
||||
"current_database_size": {
|
||||
"en": "Current Database Size",
|
||||
"zh": "当前数据库大小"
|
||||
},
|
||||
"free_plan": {
|
||||
"en": "Free",
|
||||
"zh": "Free"
|
||||
},
|
||||
"init": {
|
||||
"en": "Initialize Database",
|
||||
"zh": "初始化数据库"
|
||||
@@ -1697,6 +1847,46 @@ export const MESSAGE_REGISTRY = {
|
||||
"need_migration_tip": {
|
||||
"en": "Database migration is required. Please migrate the database.",
|
||||
"zh": "需要迁移数据库,请迁移数据库"
|
||||
},
|
||||
"paid_plan": {
|
||||
"en": "Workers Paid",
|
||||
"zh": "Workers Paid"
|
||||
},
|
||||
"plan": {
|
||||
"en": "Workers Plan",
|
||||
"zh": "Workers 套餐"
|
||||
},
|
||||
"plan_placeholder": {
|
||||
"en": "Select your Cloudflare Workers plan",
|
||||
"zh": "请选择 Cloudflare Workers 套餐"
|
||||
},
|
||||
"planSaved": {
|
||||
"en": "Workers plan saved",
|
||||
"zh": "Workers 套餐已保存"
|
||||
},
|
||||
"single_database_limit": {
|
||||
"en": "Database Capacity Limit",
|
||||
"zh": "数据库容量上限"
|
||||
},
|
||||
"storage_description": {
|
||||
"en": "Compare the current database size with your plan limits.",
|
||||
"zh": "将当前数据库大小与套餐容量上限进行对比"
|
||||
},
|
||||
"storage_tip": {
|
||||
"en": "Usage is calculated from the current database size and the selected plan limit.",
|
||||
"zh": "使用率按当前数据库大小与所选套餐的数据库容量上限计算。"
|
||||
},
|
||||
"storage_title": {
|
||||
"en": "D1 Storage Capacity",
|
||||
"zh": "D1 存储容量"
|
||||
},
|
||||
"storage_usage": {
|
||||
"en": "Capacity Usage",
|
||||
"zh": "容量使用率"
|
||||
},
|
||||
"unavailable": {
|
||||
"en": "Unavailable",
|
||||
"zh": "暂不可用"
|
||||
}
|
||||
},
|
||||
"views.admin.IpBlacklistSettings": {
|
||||
@@ -1984,6 +2174,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Create New Email",
|
||||
"zh": "创建新邮箱"
|
||||
},
|
||||
"enableCustomSubdomain": {
|
||||
"en": "Use Custom Subdomain",
|
||||
"zh": "使用自定义子域名"
|
||||
},
|
||||
"enablePrefix": {
|
||||
"en": "If enable Prefix",
|
||||
"zh": "是否启用前缀"
|
||||
@@ -1996,10 +2190,18 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Please fill in all fields",
|
||||
"zh": "请填写完整信息"
|
||||
},
|
||||
"generateName": {
|
||||
"en": "Generate Fake Name",
|
||||
"zh": "生成随机名字"
|
||||
},
|
||||
"linkWithAddressCredential": {
|
||||
"en": "Open to auto login email link",
|
||||
"zh": "打开即可自动登录邮箱的链接"
|
||||
},
|
||||
"normalSubdomain": {
|
||||
"en": "Normal Domain",
|
||||
"zh": "普通域名"
|
||||
},
|
||||
"randomSubdomainTip": {
|
||||
"en": "When enabled, the created address will use a random subdomain. Recommended for receiving only. Requires a wildcard MX DNS record on the base domain — see the random subdomain docs.",
|
||||
"zh": "启用后,创建出来的地址会自动挂在随机子域名下,建议仅用于收件。需要在基础域名 DNS 中配置通配 MX 记录,详见随机子域名文档。"
|
||||
@@ -2090,6 +2292,10 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.common.Appearance": {
|
||||
"autoLoadRemoteImages": {
|
||||
"en": "Automatically load external images in mail body",
|
||||
"zh": "自动加载邮件正文中的外部图片"
|
||||
},
|
||||
"autoRefreshInterval": {
|
||||
"en": "Auto Refresh Interval(Sec)",
|
||||
"zh": "自动刷新间隔(秒)"
|
||||
@@ -2107,8 +2313,20 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "左侧"
|
||||
},
|
||||
"mailboxSplitSize": {
|
||||
"en": "Mailbox Split Size",
|
||||
"zh": "邮箱界面分栏大小"
|
||||
"en": "Left list width in two-column mailbox view",
|
||||
"zh": "邮箱双栏视图左侧列表宽度占比"
|
||||
},
|
||||
"mailListView": {
|
||||
"en": "Full-width mailbox list view",
|
||||
"zh": "邮箱全宽列表视图"
|
||||
},
|
||||
"mailListPreviewLineClamp": {
|
||||
"en": "Body Preview Lines",
|
||||
"zh": "正文预览行数"
|
||||
},
|
||||
"off": {
|
||||
"en": "Off",
|
||||
"zh": "关闭"
|
||||
},
|
||||
"preferShowTextMail": {
|
||||
"en": "Display text Mail by default",
|
||||
@@ -2193,8 +2411,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "强制开启"
|
||||
},
|
||||
"create_address_subdomain_match_note": {
|
||||
"en": "This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.",
|
||||
"zh": "这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名;随机子域名功能只是在创建时自动补一个随机子域名。"
|
||||
"en": "RANDOM_SUBDOMAIN_DOMAINS already allows random or manual subdomains for listed base domains. This switch additionally allows API callers to specify subdomains under other allowed base domains.",
|
||||
"zh": "RANDOM_SUBDOMAIN_DOMAINS 已允许在所列基础域名下随机生成或手动输入子域名;此开关额外允许 API 在其他已授权基础域名下指定子域名。"
|
||||
},
|
||||
"create_address_subdomain_match_tip": {
|
||||
"en": "Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.",
|
||||
@@ -2474,6 +2692,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Use Random Subdomain",
|
||||
"zh": "启用随机子域名"
|
||||
},
|
||||
"enableCustomSubdomain": {
|
||||
"en": "Use Custom Subdomain",
|
||||
"zh": "使用自定义子域名"
|
||||
},
|
||||
"generateName": {
|
||||
"en": "Generate Fake Name",
|
||||
"zh": "生成随机名字"
|
||||
@@ -2482,6 +2704,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Create New Email",
|
||||
"zh": "创建新邮箱"
|
||||
},
|
||||
"normalSubdomain": {
|
||||
"en": "Normal Domain",
|
||||
"zh": "普通域名"
|
||||
},
|
||||
"getNewEmailTip1": {
|
||||
"en": "Please input the email you want to use. only allow: ",
|
||||
"zh": "请输入你想要使用的邮箱地址, 只允许: "
|
||||
|
||||
@@ -24,6 +24,8 @@ export const useGlobalState = createGlobalState(
|
||||
disableAnonymousUserCreateEmail: false,
|
||||
disableCustomAddressName: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableMailReadStatus: false,
|
||||
enableMailFlagged: false,
|
||||
enableAutoReply: false,
|
||||
enableIndexAbout: false,
|
||||
/** @type {string[]} */
|
||||
@@ -88,6 +90,8 @@ export const useGlobalState = createGlobalState(
|
||||
const adminMailTabAddress = ref("");
|
||||
const adminSendBoxTabAddress = ref("");
|
||||
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
|
||||
const mailListView = useStorage('mailListView', false);
|
||||
const mailListPreviewLineClamp = useStorage('mailListPreviewLineClamp', 2);
|
||||
const useIframeShowMail = useStorage('useIframeShowMail', false);
|
||||
const preferShowTextMail = useStorage('preferShowTextMail', false);
|
||||
const userJwt = useStorage('userJwt', '');
|
||||
@@ -97,6 +101,7 @@ export const useGlobalState = createGlobalState(
|
||||
const globalTabplacement = useStorage('globalTabplacement', 'top');
|
||||
const useSideMargin = useStorage('useSideMargin', true);
|
||||
const useUTCDate = useStorage('useUTCDate', false);
|
||||
const autoLoadRemoteImages = useStorage('autoLoadRemoteImages', true);
|
||||
const autoRefresh = useStorage('autoRefresh', false);
|
||||
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
|
||||
const userOpenSettings = ref({
|
||||
@@ -160,6 +165,8 @@ export const useGlobalState = createGlobalState(
|
||||
adminMailTabAddress,
|
||||
adminSendBoxTabAddress,
|
||||
mailboxSplitSize,
|
||||
mailListView,
|
||||
mailListPreviewLineClamp,
|
||||
useIframeShowMail,
|
||||
preferShowTextMail,
|
||||
userJwt,
|
||||
@@ -171,6 +178,7 @@ export const useGlobalState = createGlobalState(
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
useUTCDate,
|
||||
autoLoadRemoteImages,
|
||||
autoRefresh,
|
||||
configAutoRefreshInterval,
|
||||
telegramApp,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { blockRemoteContent } from '../remote-content-policy';
|
||||
|
||||
const T = 'https://tracker.example/p.png';
|
||||
|
||||
function leaks(html) {
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
const f = [];
|
||||
for (const el of host.querySelectorAll('*')) {
|
||||
for (const a of el.attributes) {
|
||||
if (a.name.startsWith('data-blocked-')) continue;
|
||||
if (/tracker\.example/i.test(a.value)) f.push(`${el.tagName}[${a.name}]`);
|
||||
}
|
||||
if (el.tagName === 'STYLE' && /tracker\.example/i.test(el.textContent || '')) f.push('STYLE-text');
|
||||
}
|
||||
if (/tracker\.example/i.test(host.innerHTML) && !f.length) f.push('RAW');
|
||||
return f;
|
||||
}
|
||||
|
||||
const TAB = String.fromCharCode(9);
|
||||
const NUL = String.fromCharCode(1);
|
||||
|
||||
const V = [
|
||||
['noscript 突破', `<p>hi</p><noscript><b title="</noscript><img src=${T}>"></noscript>`],
|
||||
['base href', `<base href="https://tracker.example/"><img src="/logo.png">`],
|
||||
['iframe srcdoc', `<iframe srcdoc="<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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeHtml } from '../sanitize-html';
|
||||
|
||||
describe('sanitizeHtml', () => {
|
||||
it('preserves safe announcement markup', () => {
|
||||
expect(sanitizeHtml('<strong>Notice</strong>')).toBe('<strong>Notice</strong>');
|
||||
});
|
||||
|
||||
it('removes executable markup and unsafe attributes', () => {
|
||||
const sanitized = sanitizeHtml(
|
||||
'<script>alert(1)</script><img src="x" onerror="alert(1)">'
|
||||
);
|
||||
|
||||
expect(sanitized).not.toContain('<script');
|
||||
expect(sanitized).not.toContain('onerror');
|
||||
expect(sanitized).toContain('<img src="x">');
|
||||
});
|
||||
|
||||
it('returns an empty string for non-string values', () => {
|
||||
expect(sanitizeHtml(null)).toBe('');
|
||||
expect(sanitizeHtml({ value: '<strong>unsafe ref</strong>' })).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -80,3 +80,4 @@ export function getDownloadEmlUrl(raw) {
|
||||
new Blob([raw], { type: 'text/plain' }
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// 1x1 transparent GIF, substituted for blocked remote images.
|
||||
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7';
|
||||
|
||||
// Attributes whose value the browser resolves into a request.
|
||||
const URL_ATTRIBUTES = new Set([
|
||||
'src', 'srcset', 'imagesrcset', 'href', 'xlink:href',
|
||||
'poster', 'background', 'data', 'action', 'formaction',
|
||||
]);
|
||||
const NAVIGATION_HREF_ELEMENTS = new Set(['A', 'AREA']);
|
||||
|
||||
// Elements that fetch on their own, redirect the frame, or re-base every
|
||||
// relative URL in the document. None of them belong in a mail body, and
|
||||
// <base> in particular would turn the relative paths we deliberately keep
|
||||
// into requests to whatever host it names.
|
||||
const FORBIDDEN = [
|
||||
'base', 'meta', 'script', 'link', 'iframe', 'frame', 'frameset',
|
||||
'object', 'embed', 'noscript', 'template', 'portal',
|
||||
];
|
||||
|
||||
// DOMPurify's default scheme list has no blob:, but email-parser.js rewrites
|
||||
// cid: attachments into blob: URLs -- without this every inline image would be
|
||||
// stripped along with the trackers.
|
||||
const ALLOWED_URI_REGEXP =
|
||||
/^(?:(?:https?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
|
||||
|
||||
// CSS constructs that can load a resource. @import is listed because it also
|
||||
// accepts a bare string -- `@import "https://..."` fetches without any url().
|
||||
const CSS_FETCHES = /url\(|image-set|image\(|cross-fade|element\(|@import/i;
|
||||
// A url(...) token or a bare string, either of which can name a resource.
|
||||
const CSS_TOKEN = /url\(\s*(['"]?)([^'")]*)\1\s*\)|(['"])([^'"]*)\3/g;
|
||||
const CSS_ESCAPE = /\\([0-9a-f]{1,6})[ \t\r\n\f]?|\\([^\r\n\f0-9a-f])/gi;
|
||||
const CSS_ESCAPED_IDENTIFIER =
|
||||
/@?(?:[-_a-z0-9]|\\(?:[0-9a-f]{1,6}[ \t\r\n\f]?|[^\r\n\f0-9a-f]))+/gi;
|
||||
const CSS_FETCH_IDENTIFIERS = new Set([
|
||||
'url', 'image-set', '-webkit-image-set', 'image', 'cross-fade',
|
||||
'-webkit-cross-fade', 'element', '-moz-element', '@import',
|
||||
]);
|
||||
|
||||
function decodeCssEscapes(value) {
|
||||
return value.replace(CSS_ESCAPE, (_match, hex, escaped) => {
|
||||
if (!hex) {
|
||||
return escaped;
|
||||
}
|
||||
const codePoint = Number.parseInt(hex, 16);
|
||||
if (codePoint === 0 || codePoint > 0x10FFFF ||
|
||||
(codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
|
||||
return '\uFFFD';
|
||||
}
|
||||
return String.fromCodePoint(codePoint);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCssFetchIdentifiers(value) {
|
||||
return value.replace(CSS_ESCAPED_IDENTIFIER, (identifier) => {
|
||||
if (!identifier.includes('\\')) {
|
||||
return identifier;
|
||||
}
|
||||
const decoded = decodeCssEscapes(identifier);
|
||||
return CSS_FETCH_IDENTIFIERS.has(decoded.toLowerCase()) ? decoded : identifier;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a URL can be *proven* to stay off the network.
|
||||
*
|
||||
* This is deliberately an allowlist. Asking "does this look remote?" means
|
||||
* enumerating every way a scheme can be disguised -- backslashes, tabs and
|
||||
* control characters the URL parser strips, CSS escapes, schemes with no
|
||||
* slashes -- and losing to the first one not thought of. Asking "can I prove
|
||||
* this is local?" fails closed instead: anything unrecognised is blocked.
|
||||
*
|
||||
* Relative paths qualify only because <base> is removed above, so they can
|
||||
* resolve to nothing but our own origin.
|
||||
*/
|
||||
function provablyLocal(value) {
|
||||
const url = String(value ?? '').trim();
|
||||
if (url === '') {
|
||||
return true;
|
||||
}
|
||||
if (/^(?:cid:|blob:|data:image\/)/i.test(url)) {
|
||||
return true;
|
||||
}
|
||||
// Relative: starts with a path/query/fragment marker and is not the
|
||||
// protocol-relative "//host" form (or its backslash equivalent).
|
||||
if (/^[/.?#]/.test(url)) {
|
||||
return !/^[/\\]{2}/.test(url);
|
||||
}
|
||||
// No scheme separator and no backslash at all -- a bare relative filename.
|
||||
return !/[:\\]/.test(url);
|
||||
}
|
||||
|
||||
/** srcset holds several candidates; every one of them has to be local. */
|
||||
function srcsetIsLocal(value) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((candidate) => candidate.trim().split(/\s+/)[0])
|
||||
.filter(Boolean)
|
||||
.every(provablyLocal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces every resource reference in a chunk of CSS that cannot be proven
|
||||
* local. Tokens are substituted in place rather than whole declarations
|
||||
* dropped, so the surrounding rule structure survives.
|
||||
*/
|
||||
function blockCssUrls(cssText, onBlocked) {
|
||||
const normalizedCssText = normalizeCssFetchIdentifiers(cssText);
|
||||
if (!CSS_FETCHES.test(normalizedCssText)) {
|
||||
return cssText;
|
||||
}
|
||||
return normalizedCssText.replace(CSS_TOKEN, (match, urlQuote, urlValue, strQuote, strValue) => {
|
||||
const isUrlToken = urlValue !== undefined;
|
||||
const token = isUrlToken ? urlValue : strValue;
|
||||
if (provablyLocal(token)) {
|
||||
return match;
|
||||
}
|
||||
onBlocked();
|
||||
return isUrlToken
|
||||
? `url(${urlQuote}${TRANSPARENT_PIXEL}${urlQuote})`
|
||||
: `${strQuote}${TRANSPARENT_PIXEL}${strQuote}`;
|
||||
});
|
||||
}
|
||||
|
||||
let purifier = null;
|
||||
let blockedCount = 0;
|
||||
|
||||
/**
|
||||
* An isolated DOMPurify instance. The hooks below must not reach the shared
|
||||
* singleton, which mail-actions.js uses when building replies -- quoting a
|
||||
* mail should keep its images.
|
||||
*/
|
||||
function getPurifier() {
|
||||
if (purifier) {
|
||||
return purifier;
|
||||
}
|
||||
purifier = DOMPurify(window);
|
||||
|
||||
purifier.addHook('uponSanitizeAttribute', (node, data) => {
|
||||
if (!URL_ATTRIBUTES.has(data.attrName)) {
|
||||
return;
|
||||
}
|
||||
if (data.attrName === 'href' && NAVIGATION_HREF_ELEMENTS.has(node.tagName)) {
|
||||
return;
|
||||
}
|
||||
const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset';
|
||||
if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
blockedCount += 1;
|
||||
data.keepAttr = false;
|
||||
// The original URL is dropped rather than parked in a data-* attribute:
|
||||
// "the cleaned body contains no remote URL at all" is an invariant that
|
||||
// can be asserted directly, and restoring images re-renders from the
|
||||
// untouched source anyway. <img> keeps a placeholder so layout holds.
|
||||
if (data.attrName === 'src' && node.tagName === 'IMG') {
|
||||
node.setAttribute('src', TRANSPARENT_PIXEL);
|
||||
}
|
||||
});
|
||||
|
||||
purifier.addHook('afterSanitizeElements', (node) => {
|
||||
if (node.tagName === 'STYLE') {
|
||||
const cleaned = blockCssUrls(node.textContent || '', () => { blockedCount += 1; });
|
||||
if (cleaned !== node.textContent) {
|
||||
node.textContent = cleaned;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
purifier.addHook('afterSanitizeAttributes', (node) => {
|
||||
const style = node.getAttribute && node.getAttribute('style');
|
||||
if (!style) {
|
||||
return;
|
||||
}
|
||||
const cleaned = blockCssUrls(style, () => { blockedCount += 1; });
|
||||
if (cleaned !== style) {
|
||||
node.setAttribute('style', cleaned);
|
||||
}
|
||||
});
|
||||
|
||||
return purifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips everything in an email body that would make the browser fetch from a
|
||||
* third party, so opening the mail cannot be used to confirm it was read.
|
||||
*
|
||||
* Sanitising is delegated to DOMPurify rather than hand-rolled: the hard part
|
||||
* is not enumerating attributes but surviving the parser, and mutation-XSS is
|
||||
* DOMPurify's specialty. A hand-written pass over a DOMParser tree missed, for
|
||||
* one example, that <noscript> is parsed as markup where scripting is off and
|
||||
* as raw text where it is on -- so a `</noscript>` smuggled into an attribute
|
||||
* value reopens the document at render time and revives an <img> that the
|
||||
* cleaner never saw.
|
||||
*
|
||||
* @param {string} html
|
||||
* @returns {{ html: string, blocked: number }} blocked counts the references removed
|
||||
*/
|
||||
export function blockRemoteContent(html) {
|
||||
if (!html || typeof html !== 'string') {
|
||||
return { html: html || '', blocked: 0 };
|
||||
}
|
||||
|
||||
blockedCount = 0;
|
||||
const sanitised = getPurifier().sanitize(html, {
|
||||
FORBID_TAGS: FORBIDDEN,
|
||||
// Mail layout leans on <style> blocks, so they are kept and their
|
||||
// url() references filtered instead of dropping the tag wholesale.
|
||||
// FORCE_BODY is what makes a leading <style> survive: without it the
|
||||
// parser hoists it into <head> and DOMPurify discards it.
|
||||
ADD_TAGS: ['style'],
|
||||
FORCE_BODY: true,
|
||||
ALLOWED_URI_REGEXP,
|
||||
ALLOW_DATA_ATTR: true,
|
||||
});
|
||||
|
||||
return { html: sanitised, blocked: blockedCount };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
export const sanitizeHtml = (html) => {
|
||||
return DOMPurify.sanitize(typeof html === 'string' ? html : '');
|
||||
};
|
||||
@@ -120,7 +120,7 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="quick-setup-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
@@ -136,7 +136,7 @@ onMounted(async () => {
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<Account />
|
||||
</n-tab-pane>
|
||||
@@ -161,7 +161,7 @@ onMounted(async () => {
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user" :tab="t('user')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="user-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="user_management" :tab="t('user_management')">
|
||||
<UserManagement />
|
||||
</n-tab-pane>
|
||||
@@ -177,7 +177,7 @@ onMounted(async () => {
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="mails-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
@@ -202,7 +202,7 @@ onMounted(async () => {
|
||||
<Statistics />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -435,6 +435,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:deep(.n-page-header) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
:deep(.n-page-header__title) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -32,19 +32,41 @@ const SendMail = defineAsyncComponent(() => {
|
||||
|
||||
const { t } = useScopedI18n('views.Index')
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
const fetchMailData = async (limit, offset, mailState, flaggedOnly) => {
|
||||
if (mailIdQuery.value > 0) {
|
||||
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
|
||||
if (singleMail) return { results: [singleMail], count: 1 };
|
||||
return { results: [], count: 0 };
|
||||
}
|
||||
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
|
||||
const mailStateQuery = mailState ? `&mail_state=${encodeURIComponent(mailState)}` : ''
|
||||
const flaggedQuery = flaggedOnly ? '&flagged=true' : ''
|
||||
return await api.fetch(
|
||||
`/api/mails?limit=${limit}&offset=${offset}${mailStateQuery}${flaggedQuery}`
|
||||
);
|
||||
};
|
||||
|
||||
const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const updateMailState = async (ids, state) => {
|
||||
return await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, state })
|
||||
});
|
||||
};
|
||||
|
||||
const updateMailFlagged = async (ids, flagged) => {
|
||||
return await api.fetch(`/api/mails/flagged`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, flagged })
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMailStates = async () => {
|
||||
return await api.fetch(`/api/mail-states`)
|
||||
}
|
||||
|
||||
const deleteSenboxMail = async (curMailId) => {
|
||||
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
@@ -127,7 +149,10 @@ onMounted(() => {
|
||||
</div>
|
||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
|
||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||
:enableMailFlagged="openSettings.enableMailFlagged" :updateMailState="updateMailState"
|
||||
:updateMailFlagged="updateMailFlagged" :fetchMailStates="fetchMailStates" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||
|
||||
@@ -8,12 +8,14 @@ import UserSettingsPage from './user/UserSettings.vue';
|
||||
import UserBar from './user/UserBar.vue';
|
||||
import BindAddress from './user/BindAddress.vue';
|
||||
import UserMailBox from './user/UserMailBox.vue';
|
||||
import UserSendBox from './user/UserSendBox.vue';
|
||||
|
||||
const {
|
||||
userTab, globalTabplacement, userSettings
|
||||
userTab, globalTabplacement, userSettings, openSettings
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.User')
|
||||
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
|
||||
|
||||
</script>
|
||||
|
||||
@@ -27,6 +29,12 @@ const { t } = useScopedI18n('views.User')
|
||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||
<UserMailBox />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="user_sendbox" :tab="userMailT('sendbox')">
|
||||
<UserSendBox mode="sendbox" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
|
||||
<UserSendBox mode="send_mail" @sent="userTab = 'user_sendbox'" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
<UserSettingsPage />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -14,7 +14,8 @@ const message = useMessage()
|
||||
const { t } = useScopedI18n('views.admin.CreateAccount')
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const subdomainMode = ref("normal")
|
||||
const customSubdomain = ref("")
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
@@ -22,6 +23,40 @@ const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const addressRegex = computed(() => {
|
||||
try {
|
||||
if (openSettings.value.addressRegex) {
|
||||
return new RegExp(openSettings.value.addressRegex, 'g');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
|
||||
}
|
||||
return /[^a-z0-9]/g;
|
||||
});
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.internet.email()
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(addressRegex.value, '')
|
||||
.toLowerCase();
|
||||
// support maxAddressLen
|
||||
if (emailName.value.length > openSettings.value.maxAddressLen) {
|
||||
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
generateNameLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
@@ -31,7 +66,7 @@ const canUseRandomSubdomain = computed(() => {
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false
|
||||
subdomainMode.value = "normal"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,13 +76,16 @@ const newEmail = async () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const domain = subdomainMode.value === "custom"
|
||||
? `${customSubdomain.value.trim()}.${emailDomain.value}`
|
||||
: emailDomain.value
|
||||
const res = await api.fetch(`/admin/new_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
enableRandomSubdomain: enableRandomSubdomain.value,
|
||||
enableRandomSubdomain: subdomainMode.value === "random",
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
domain,
|
||||
})
|
||||
})
|
||||
result.value = res["jwt"];
|
||||
@@ -77,25 +115,43 @@ onMounted(async () => {
|
||||
<n-switch v-model:value="enablePrefix" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('address')">
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
<n-spin :show="generateNameLoading" style="width: 100%;">
|
||||
<div>
|
||||
<n-button @click="generateName" style="margin-bottom: 10px;">
|
||||
{{ t('generateName') }}
|
||||
</n-button>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<div style="width: 100%;">
|
||||
<n-radio-group v-model:value="subdomainMode">
|
||||
<n-space vertical>
|
||||
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
|
||||
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
|
||||
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
|
||||
<n-input v-model:value="customSubdomain" />
|
||||
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading"
|
||||
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
|
||||
{{ t('creatNewEmail') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { CleaningServicesFilled } from '@vicons/material'
|
||||
|
||||
@@ -7,24 +7,95 @@ import { api } from '../../api'
|
||||
import { init } from 'vooks/lib/on-fonts-ready';
|
||||
|
||||
const message = useMessage()
|
||||
const D1_STORAGE_PLAN_CONFIG_KEY = 'd1_storage_plan'
|
||||
const dbVersionData = ref({
|
||||
need_initialization: false,
|
||||
need_migration: false,
|
||||
current_db_version: '',
|
||||
code_db_version: ''
|
||||
code_db_version: '',
|
||||
database_size: null
|
||||
})
|
||||
const selectedPlan = ref(null)
|
||||
const savedPlan = ref(null)
|
||||
const savingPlan = ref(false)
|
||||
|
||||
const planOptions = computed(() => [
|
||||
{
|
||||
label: t('free_plan'),
|
||||
value: 'free',
|
||||
databaseLimit: 500 * 1024 ** 2
|
||||
},
|
||||
{
|
||||
label: t('paid_plan'),
|
||||
value: 'paid',
|
||||
databaseLimit: 10 * 1024 ** 3
|
||||
}
|
||||
])
|
||||
|
||||
const selectedPlanDetails = computed(() => (
|
||||
planOptions.value.find((plan) => plan.value === selectedPlan.value)
|
||||
))
|
||||
|
||||
const storagePercentage = computed(() => {
|
||||
if (!selectedPlanDetails.value || dbVersionData.value.database_size === null) return 0
|
||||
return dbVersionData.value.database_size / selectedPlanDetails.value.databaseLimit * 100
|
||||
})
|
||||
|
||||
const progressPercentage = computed(() => Math.min(storagePercentage.value, 100))
|
||||
|
||||
const progressStatus = computed(() => {
|
||||
if (storagePercentage.value >= 90) return 'error'
|
||||
if (storagePercentage.value >= 75) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const { t } = useScopedI18n('views.admin.DatabaseManager')
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === null || bytes === undefined) return t('unavailable')
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** unitIndex
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch('/admin/db_version');
|
||||
if (res) Object.assign(dbVersionData.value, res);
|
||||
const [versionRes, configRes] = await Promise.all([
|
||||
api.fetch('/admin/db_version'),
|
||||
api.fetch(`/admin/config/${D1_STORAGE_PLAN_CONFIG_KEY}`)
|
||||
]);
|
||||
if (versionRes) Object.assign(dbVersionData.value, versionRes);
|
||||
|
||||
const configuredPlan = configRes?.value
|
||||
if (planOptions.value.some((plan) => plan.value === configuredPlan)) {
|
||||
selectedPlan.value = configuredPlan
|
||||
savedPlan.value = configuredPlan
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const savePlan = async (plan) => {
|
||||
savingPlan.value = true
|
||||
try {
|
||||
await api.fetch('/admin/config', {
|
||||
method: 'POST',
|
||||
body: { key: D1_STORAGE_PLAN_CONFIG_KEY, value: plan }
|
||||
})
|
||||
savedPlan.value = plan
|
||||
message.success(t('planSaved'))
|
||||
} catch (error) {
|
||||
selectedPlan.value = savedPlan.value
|
||||
message.error(error.message || "error")
|
||||
} finally {
|
||||
savingPlan.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const initialization = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/db_initialize', {
|
||||
@@ -77,6 +148,58 @@ onMounted(async () => {
|
||||
</span>
|
||||
</n-alert>
|
||||
|
||||
<div class="storage-panel">
|
||||
<div class="storage-heading">
|
||||
<div>
|
||||
<h3>{{ t('storage_title') }}</h3>
|
||||
<p>{{ t('storage_description') }}</p>
|
||||
</div>
|
||||
<div class="plan-select">
|
||||
<span>{{ t('plan') }}</span>
|
||||
<n-select
|
||||
v-model:value="selectedPlan"
|
||||
:options="planOptions"
|
||||
:placeholder="t('plan_placeholder')"
|
||||
:disabled="dbVersionData.need_initialization"
|
||||
:loading="savingPlan"
|
||||
@update:value="savePlan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-grid cols="1 s:2" responsive="screen" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="storage-stat">
|
||||
<span>{{ t('current_database_size') }}</span>
|
||||
<strong>{{ formatBytes(dbVersionData.database_size) }}</strong>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="storage-stat">
|
||||
<span>{{ t('single_database_limit') }}</span>
|
||||
<strong>{{ selectedPlanDetails ? formatBytes(selectedPlanDetails.databaseLimit) : '—' }}</strong>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<div v-if="selectedPlanDetails" class="storage-progress">
|
||||
<div class="storage-progress-label">
|
||||
<span>{{ t('storage_usage') }}</span>
|
||||
<span>{{ storagePercentage.toFixed(2) }}%</span>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="progressPercentage"
|
||||
:status="progressStatus"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-alert class="storage-tip" type="default" :show-icon="false" :bordered="false">
|
||||
{{ t('storage_tip') }}
|
||||
</n-alert>
|
||||
</div>
|
||||
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,4 +223,88 @@ onMounted(async () => {
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.storage-panel {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--n-border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.storage-heading {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.storage-heading h3,
|
||||
.storage-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.storage-heading p {
|
||||
margin-top: 4px;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.plan-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.plan-select > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-text-color-2);
|
||||
}
|
||||
|
||||
.storage-stat {
|
||||
display: flex;
|
||||
min-height: 72px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: var(--n-border-radius);
|
||||
}
|
||||
|
||||
.storage-stat span {
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.storage-stat strong {
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.storage-progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.storage-progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-text-color-2);
|
||||
}
|
||||
|
||||
.storage-tip {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import { api } from '../../api'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
const { autoLoadRemoteImages, isDark } = useGlobalState()
|
||||
|
||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
fromName: "",
|
||||
@@ -23,11 +29,18 @@ const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||
|
||||
const { t } = useScopedI18n('views.admin.SendMail')
|
||||
|
||||
const contentTypes = [
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
@@ -110,6 +123,7 @@ const send = async () => {
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -146,78 +160,230 @@ const handleCreated = (editor) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input v-model:value="sendMailModel.fromMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
<div class="composer-page">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ t('adminComposeTip') }}</n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-sender-address' }">
|
||||
<n-input v-model:value="sendMailModel.fromMail"
|
||||
:input-props="{ id: 'admin-send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')"
|
||||
:label-props="{ for: 'admin-send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'admin-send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')"
|
||||
:label-props="{ for: 'admin-send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required
|
||||
:label-props="{ for: 'admin-send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'admin-send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="admin-send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="admin-send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.left {
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container),
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { GithubAlt, Discord, Telegram } from '@vicons/fa'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
const { announcement } = useGlobalState()
|
||||
const safeAnnouncement = computed(() => sanitizeHtml(announcement.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<div v-html="announcement"></div>
|
||||
<div v-html="safeAnnouncement"></div>
|
||||
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
|
||||
<template #icon>
|
||||
<n-icon :component="GithubAlt" />
|
||||
|
||||
@@ -11,8 +11,8 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const {
|
||||
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
|
||||
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
|
||||
mailboxSplitSize, mailListView, mailListPreviewLineClamp, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
|
||||
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteImages
|
||||
} = useGlobalState()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
@@ -23,12 +23,26 @@ const { t } = useScopedI18n('views.common.Appearance')
|
||||
<div class="center">
|
||||
<n-card :bordered="false" embedded>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('mailboxSplitSize')">
|
||||
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
|
||||
<n-slider v-model:value="mailboxSplitSize" :min="0" :max="0.75" :step="0.01" :marks="{
|
||||
0: '0',
|
||||
0.25: '0.25',
|
||||
0.5: '0.5',
|
||||
0.75: '0.75'
|
||||
}" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('mailListView')">
|
||||
<n-switch v-model:value="mailListView" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('mailListPreviewLineClamp')">
|
||||
<n-slider v-model:value="mailListPreviewLineClamp" :min="0" :max="5" :step="1" :marks="{
|
||||
0: t('off'),
|
||||
1: '1',
|
||||
2: '2',
|
||||
3: '3',
|
||||
4: '4',
|
||||
5: '5'
|
||||
}" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('autoRefreshInterval')">
|
||||
<n-slider v-model:value="configAutoRefreshInterval" :min="30" :max="300" :step="1" :marks="{
|
||||
60: '60', 120: '120', 180: '180', 240: '240'
|
||||
@@ -46,6 +60,9 @@ const { t } = useScopedI18n('views.common.Appearance')
|
||||
<n-form-item-row :label="t('useUTCDate')">
|
||||
<n-switch v-model:value="useUTCDate" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('autoLoadRemoteImages')">
|
||||
<n-switch v-model:value="autoLoadRemoteImages" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
|
||||
<n-switch v-model:value="useSideMargin" :round="false" />
|
||||
</n-form-item-row>
|
||||
|
||||
@@ -48,7 +48,8 @@ const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const subdomainMode = ref("normal")
|
||||
const customSubdomain = ref("")
|
||||
const loginCfToken = ref("")
|
||||
const loginTurnstileRef = ref(null)
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
@@ -167,11 +168,14 @@ const newEmail = async () => {
|
||||
try {
|
||||
// If custom names are disabled, send empty name to trigger backend auto-generation
|
||||
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
|
||||
const domainToSend = subdomainMode.value === "custom"
|
||||
? `${customSubdomain.value.trim()}.${emailDomain.value}`
|
||||
: emailDomain.value;
|
||||
const res = await props.newAddressPath(
|
||||
nameToSend,
|
||||
emailDomain.value,
|
||||
domainToSend,
|
||||
cfToken.value,
|
||||
enableRandomSubdomain.value
|
||||
subdomainMode.value === "random"
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
@@ -206,7 +210,7 @@ const canUseRandomSubdomain = computed(() => {
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false;
|
||||
subdomainMode.value = "normal";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -321,15 +325,26 @@ onMounted(async () => {
|
||||
:options="domainsOptions" />
|
||||
</n-input-group>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<div style="width: 100%;">
|
||||
<n-radio-group v-model:value="subdomainMode">
|
||||
<n-space vertical>
|
||||
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
|
||||
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
|
||||
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
|
||||
<n-input v-model:value="customSubdomain" />
|
||||
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading"
|
||||
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
|
||||
<template #icon>
|
||||
<n-icon :component="NewLabelOutlined" />
|
||||
</template>
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
@@ -14,15 +18,25 @@ const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
|
||||
|
||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
||||
const {
|
||||
settings, sendMailModel, indexTab, userSettings,
|
||||
autoLoadRemoteImages, isDark,
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.index.SendMail')
|
||||
|
||||
const contentTypes = [
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
]
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
@@ -116,7 +130,7 @@ const requestAccess = async () => {
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
)
|
||||
message.success(t("success"))
|
||||
message.success(t("requestSuccess"))
|
||||
await api.getSettings();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -157,95 +171,292 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded>
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<n-alert type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('requestAccessTip') }}
|
||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
||||
}}</n-button>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false" closable>
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input :value="settings.address" disabled />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ settings.address }}</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
<div class="admin-contact"><AdminContact /></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||
<n-input :value="settings.address" readonly
|
||||
:input-props="{ id: 'send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.access-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-copy p {
|
||||
max-width: 640px;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.65;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__main) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__extra) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,12 +26,17 @@ const message = useMessage()
|
||||
const currentPage = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const currentMail = ref(null)
|
||||
const mailStates = ref([])
|
||||
const showAccountSettingsCard = ref(false)
|
||||
const currentAutoRefreshInterval = ref(60)
|
||||
const timer = ref(null)
|
||||
|
||||
const { t } = useScopedI18n('views.index.SimpleIndex')
|
||||
|
||||
const getReadStateValue = (unread) => {
|
||||
return mailStates.value.find(state => state.unread === unread)?.value
|
||||
}
|
||||
|
||||
// 复制地址
|
||||
const copyAddress = async () => {
|
||||
try {
|
||||
@@ -50,12 +55,58 @@ const fetchMails = async () => {
|
||||
totalCount.value = count > 0 ? count : totalCount.value;
|
||||
const rawMail = results && results.length > 0 ? results[0] : null
|
||||
currentMail.value = rawMail ? await processItem(rawMail) : null
|
||||
if (openSettings.value.enableMailReadStatus && rawMail?.unread) {
|
||||
const state = getReadStateValue(false)
|
||||
if (!state) return
|
||||
const response = await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids: [rawMail.id], state })
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.unread = updatedMail.unread
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch mails:', error)
|
||||
message.error('获取邮件失败')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailUnread = async () => {
|
||||
if (!currentMail.value || !openSettings.value.enableMailReadStatus) return
|
||||
try {
|
||||
const state = getReadStateValue(!currentMail.value.unread)
|
||||
if (!state) return
|
||||
const response = await api.fetch(`/api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ids: [currentMail.value.id],
|
||||
state,
|
||||
})
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.unread = updatedMail.unread
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCurrentMailFlagged = async () => {
|
||||
if (!currentMail.value || !openSettings.value.enableMailFlagged) return
|
||||
try {
|
||||
const response = await api.fetch(`/api/mails/flagged`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ids: [currentMail.value.id],
|
||||
flagged: !currentMail.value.flagged,
|
||||
})
|
||||
})
|
||||
const updatedMail = response.results?.[0]
|
||||
if (updatedMail) currentMail.value.flagged = updatedMail.flagged
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除邮件
|
||||
const deleteMail = async () => {
|
||||
if (!currentMail.value) return;
|
||||
@@ -106,6 +157,15 @@ watch(currentPage, () => {
|
||||
|
||||
onMounted(async () => {
|
||||
await api.getSettings()
|
||||
if (openSettings.value.enableMailReadStatus) {
|
||||
try {
|
||||
const { results = [] } = await api.fetch(`/api/mail-states`)
|
||||
mailStates.value = results
|
||||
} catch (error) {
|
||||
mailStates.value = []
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
await fetchMails()
|
||||
|
||||
// 启动自动刷新
|
||||
@@ -220,6 +280,10 @@ onBeforeUnmount(() => {
|
||||
<div style="margin-top: 16px;">
|
||||
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||
:enableMailFlagged="openSettings.enableMailFlagged"
|
||||
:onToggleUnread="toggleCurrentMailUnread"
|
||||
:onToggleFlagged="toggleCurrentMailFlagged"
|
||||
:onDelete="deleteMail" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, h, onMounted } from 'vue';
|
||||
import { ref, h, onMounted, watch } from 'vue';
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
@@ -7,6 +7,7 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { getRouterPathWithLang } from '../../utils'
|
||||
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||
|
||||
import Login from '../common/Login.vue';
|
||||
|
||||
@@ -15,12 +16,30 @@ const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
||||
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
|
||||
|
||||
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)
|
||||
const targetUserEmail = ref('')
|
||||
const showAddressCredential = ref(false)
|
||||
const currentAddressCredential = ref('')
|
||||
const credentialAddress = ref('')
|
||||
|
||||
const showCredential = async (row) => {
|
||||
try {
|
||||
const { jwt: addressCredential } = await api.fetch(`/user_api/bind_address_jwt/${row.id}`)
|
||||
currentAddressCredential.value = addressCredential
|
||||
credentialAddress.value = row.name
|
||||
showAddressCredential.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error")
|
||||
}
|
||||
}
|
||||
|
||||
const changeMailAddress = async (address_id) => {
|
||||
try {
|
||||
@@ -46,7 +65,11 @@ const unbindAddress = async (address_id) => {
|
||||
body: JSON.stringify({ address_id })
|
||||
});
|
||||
message.success(t('unbindAddress') + " " + t('success'));
|
||||
await fetchData();
|
||||
if (page.value === 1) {
|
||||
await fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
@@ -71,7 +94,11 @@ const transferAddress = async () => {
|
||||
})
|
||||
});
|
||||
message.success(t('transferAddress') + " " + t('success'));
|
||||
await fetchData();
|
||||
if (page.value === 1) {
|
||||
await fetchData();
|
||||
} else {
|
||||
page.value = 1;
|
||||
}
|
||||
showTranferAddress.value = false;
|
||||
currentAddressId.value = 0;
|
||||
currentAddress.value = "";
|
||||
@@ -84,10 +111,17 @@ const transferAddress = async () => {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/user_api/bind_address`
|
||||
const params = new URLSearchParams({
|
||||
limit: String(pageSize.value),
|
||||
offset: String((page.value - 1) * pageSize.value),
|
||||
});
|
||||
const { results, count: addressCount } = await api.fetch(
|
||||
`/user_api/bind_address?${params.toString()}`
|
||||
);
|
||||
data.value = results;
|
||||
if (page.value === 1) {
|
||||
count.value = addressCount;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
message.error(error.message || "error");
|
||||
@@ -128,6 +162,14 @@ const columns = [
|
||||
key: 'actions',
|
||||
render(row) {
|
||||
return h('div', [
|
||||
h(NButton,
|
||||
{
|
||||
tertiary: true,
|
||||
type: "primary",
|
||||
onClick: () => showCredential(row)
|
||||
},
|
||||
{ default: () => credentialT('addressCredential') }
|
||||
),
|
||||
h(NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => changeMailAddress(row.id)
|
||||
@@ -178,10 +220,16 @@ const columns = [
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
watch([page, pageSize], async () => {
|
||||
await fetchData();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||
:jwt="currentAddressCredential" />
|
||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||
<span>
|
||||
<p>{{ t("transferAddressTip") }}</p>
|
||||
@@ -197,6 +245,12 @@ onMounted(async () => {
|
||||
<n-tabs type="segment">
|
||||
<n-tab-pane name="address" :tab="t('address')">
|
||||
<div class="address-table-scroll">
|
||||
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||
:page-sizes="[20, 50, 100]" show-size-picker>
|
||||
<template #prefix="{ itemCount }">
|
||||
{{ t('itemCount') }}: {{ itemCount }}
|
||||
</template>
|
||||
</n-pagination>
|
||||
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
@@ -216,4 +270,9 @@ onMounted(async () => {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.n-pagination {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
<script setup>
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||
import { SendRound } from '@vicons/material'
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||
|
||||
const message = useMessage()
|
||||
const isPreview = ref(false)
|
||||
const editorRef = shallowRef()
|
||||
const sending = ref(false)
|
||||
const settings = ref({
|
||||
address: '',
|
||||
send_balance: 0,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
addressId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
addressOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
addressLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['addressScroll', 'sent', 'update:addressId'])
|
||||
|
||||
const {
|
||||
sendMailModel, userSettings, autoLoadRemoteImages, isDark,
|
||||
} = useGlobalState()
|
||||
|
||||
const { t } = useScopedI18n('views.index.SendMail')
|
||||
|
||||
const getApiPath = (path) => `/user_api/address/${props.addressId}/${path}`
|
||||
|
||||
const refreshSettings = async () => {
|
||||
settings.value = await api.fetch(getApiPath('settings'))
|
||||
}
|
||||
|
||||
const contentTypes = computed(() => [
|
||||
{ label: t('text'), value: 'text' },
|
||||
{ label: t('html'), value: 'html' },
|
||||
{ label: t('rich text'), value: 'rich' },
|
||||
])
|
||||
|
||||
const previewContent = computed(() => {
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
return autoLoadRemoteImages.value
|
||||
? sanitizeHtml(content)
|
||||
: blockRemoteContent(content).html
|
||||
})
|
||||
|
||||
const normalizeSendMailText = (content) => {
|
||||
return content
|
||||
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
const hasSendMailContent = (content, contentType) => {
|
||||
if (typeof content !== 'string' || !content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (contentType === 'text') {
|
||||
return normalizeSendMailText(content).length > 0
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = content
|
||||
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||
|
||||
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||
if (plainContent.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (sending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||
const content = `${sendMailModel.value.content ?? ''}`
|
||||
|
||||
if (!subject) {
|
||||
message.error(t('subjectEmpty'))
|
||||
return
|
||||
}
|
||||
if (!toMail) {
|
||||
message.error(t('toMailEmpty'))
|
||||
return
|
||||
}
|
||||
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||
message.error(t('contentEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
from_name: sendMailModel.value.fromName,
|
||||
to_name: sendMailModel.value.toName,
|
||||
to_mail: toMail,
|
||||
subject,
|
||||
is_html: sendMailModel.value.contentType != 'text',
|
||||
content,
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
try {
|
||||
await api.fetch(getApiPath('send_mail'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
sendMailModel.value = {
|
||||
fromName: "",
|
||||
toName: "",
|
||||
toMail: "",
|
||||
subject: "",
|
||||
contentType: 'text',
|
||||
content: "",
|
||||
}
|
||||
isPreview.value = false
|
||||
message.success(t("successSend"));
|
||||
emit('sent')
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const requestAccess = async () => {
|
||||
try {
|
||||
await api.fetch(getApiPath('request_send_mail_access'),
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
)
|
||||
message.success(t("requestSuccess"))
|
||||
await refreshSettings();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarConfig = {
|
||||
excludeKeys: ["uploadVideo"]
|
||||
}
|
||||
|
||||
const editorConfig = {
|
||||
MENU_CONF: {
|
||||
'uploadImage': {
|
||||
async customUpload() {
|
||||
message.error(t('tooLarge'))
|
||||
},
|
||||
maxFileSize: 1 * 1024 * 1024,
|
||||
base64LimitSize: 1 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor == null) return
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// make sure user_id is fetched
|
||||
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||
await refreshSettings();
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||
<n-select class="address-picker-select" :value="addressId"
|
||||
:options="addressOptions" :loading="addressLoading" filterable
|
||||
@scroll="emit('addressScroll', $event)"
|
||||
@update:value="emit('update:addressId', $event)" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
<div class="admin-contact"><AdminContact /></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.access-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-copy p {
|
||||
max-width: 640px;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.65;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__main) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__extra) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -20,11 +20,13 @@ const queryMail = () => {
|
||||
mailBoxKey.value = Date.now();
|
||||
}
|
||||
|
||||
const fetchMailData = async (limit, offset) => {
|
||||
const fetchMailData = async (limit, offset, mailState, flaggedOnly) => {
|
||||
return await api.fetch(
|
||||
`/user_api/mails`
|
||||
+ `?limit=${limit}`
|
||||
+ `&offset=${offset}`
|
||||
+ (mailState ? `&mail_state=${encodeURIComponent(mailState)}` : '')
|
||||
+ (flaggedOnly ? '&flagged=true' : '')
|
||||
+ (addressFilter.value ? `&address=${addressFilter.value}` : '')
|
||||
);
|
||||
}
|
||||
@@ -32,7 +34,7 @@ const fetchMailData = async (limit, offset) => {
|
||||
const fetchAddresData = async () => {
|
||||
try {
|
||||
const { results } = await api.fetch(
|
||||
`/user_api/bind_address`
|
||||
`/user_api/bind_address?limit=100&offset=0`
|
||||
);
|
||||
addressFilterOptions.value = results.map((item) => {
|
||||
return {
|
||||
@@ -50,6 +52,24 @@ const deleteMail = async (curMailId) => {
|
||||
await api.fetch(`/user_api/mails/${curMailId}`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
const updateMailState = async (ids, state) => {
|
||||
return await api.fetch(`/user_api/mails/state`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, state })
|
||||
});
|
||||
};
|
||||
|
||||
const updateMailFlagged = async (ids, flagged) => {
|
||||
return await api.fetch(`/user_api/mails/flagged`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, flagged })
|
||||
});
|
||||
};
|
||||
|
||||
const fetchMailStates = async () => {
|
||||
return await api.fetch(`/user_api/mail-states`)
|
||||
}
|
||||
|
||||
watch(addressFilter, async (newValue) => {
|
||||
queryMail();
|
||||
});
|
||||
@@ -70,6 +90,10 @@ onMounted(() => {
|
||||
</n-input-group>
|
||||
<div style="margin-top: 10px;"></div>
|
||||
<MailBox :key="mailBoxKey" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :fetchMailData="fetchMailData"
|
||||
:deleteMail="deleteMail" :showFilterInput="true" />
|
||||
:deleteMail="deleteMail" :showFilterInput="true"
|
||||
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||
:enableMailFlagged="openSettings.enableMailFlagged"
|
||||
:updateMailState="updateMailState" :updateMailFlagged="updateMailFlagged"
|
||||
:fetchMailStates="fetchMailStates" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { api } from '../../api'
|
||||
import { useGlobalState } from '../../store'
|
||||
import SendBox from '../../components/SendBox.vue'
|
||||
|
||||
const SendMail = defineAsyncComponent(() => import('./SendMail.vue'))
|
||||
|
||||
const ADDRESS_PAGE_SIZE = 100
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'send_mail',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sent'])
|
||||
|
||||
const message = useMessage()
|
||||
const { openSettings } = useGlobalState()
|
||||
const { t } = useScopedI18n('views.user.UserSendBox')
|
||||
const { t: mailboxT } = useScopedI18n('views.user.UserMailBox')
|
||||
|
||||
const selectedAddressId = ref(null)
|
||||
const addressFilter = ref(null)
|
||||
const addressOptions = ref([])
|
||||
const addressCount = ref(0)
|
||||
const addressLoading = ref(false)
|
||||
const sendboxKey = ref(0)
|
||||
|
||||
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
|
||||
const addressFilterOptions = computed(() => addressOptions.value.map((address) => ({
|
||||
label: address.label,
|
||||
value: address.address,
|
||||
})))
|
||||
|
||||
const fetchAddresses = async () => {
|
||||
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const offset = addressOptions.value.length
|
||||
const { results, count } = await api.fetch(
|
||||
`/user_api/bind_address?limit=${ADDRESS_PAGE_SIZE}&offset=${offset}`
|
||||
)
|
||||
addressOptions.value.push(...results.map((address) => ({
|
||||
label: address.name,
|
||||
value: address.id,
|
||||
address: address.name,
|
||||
})))
|
||||
if (offset === 0) {
|
||||
addressCount.value = count
|
||||
}
|
||||
if (props.mode === 'send_mail' && !selectedAddressId.value && addressOptions.value.length > 0) {
|
||||
selectedAddressId.value = addressOptions.value[0].value
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || 'error')
|
||||
} finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddressScroll = async (event) => {
|
||||
const target = event.currentTarget
|
||||
if (!target || target.scrollTop + target.clientHeight < target.scrollHeight - 24) {
|
||||
return
|
||||
}
|
||||
await fetchAddresses()
|
||||
}
|
||||
|
||||
const fetchSendbox = async (limit, offset) => {
|
||||
return await api.fetch(
|
||||
`/user_api/sendbox?limit=${limit}&offset=${offset}`
|
||||
+ (addressFilter.value ? `&address=${encodeURIComponent(addressFilter.value)}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
const deleteSendboxMail = async (mailId) => {
|
||||
await api.fetch(`/user_api/sendbox/${mailId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
const querySendbox = () => {
|
||||
sendboxKey.value = Date.now()
|
||||
}
|
||||
|
||||
watch(addressFilter, querySendbox)
|
||||
|
||||
onMounted(fetchAddresses)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-send-box">
|
||||
<template v-if="mode === 'send_mail'">
|
||||
<n-empty v-if="!selectedAddressId" class="address-empty" :description="t('noAddress')" />
|
||||
<SendMail v-else :key="selectedAddressId" v-model:address-id="selectedAddressId"
|
||||
:address-options="addressOptions" :address-loading="addressLoading"
|
||||
@address-scroll="handleAddressScroll" @sent="emit('sent')" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<n-input-group>
|
||||
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
|
||||
:loading="addressLoading" :placeholder="mailboxT('addressQueryTip')"
|
||||
@scroll="handleAddressScroll" />
|
||||
<n-button @click="querySendbox" type="primary" tertiary>
|
||||
{{ mailboxT('query') }}
|
||||
</n-button>
|
||||
</n-input-group>
|
||||
<div class="filter-spacing"></div>
|
||||
<SendBox :key="sendboxKey" :fetch-mail-data="fetchSendbox" show-e-mail-from
|
||||
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
|
||||
:delete-mail="deleteSendboxMail" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-send-box {
|
||||
padding-top: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.filter-spacing {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.address-empty {
|
||||
padding: 72px 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "temp-email-pages",
|
||||
"version": "1.9.0",
|
||||
"version": "1.12.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.96.0"
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
set -euo pipefail
|
||||
|
||||
cd frontend/
|
||||
pnpm up
|
||||
pnpm add -D wrangler@latest
|
||||
@@ -17,3 +19,18 @@ cd vitepress-docs/
|
||||
pnpm up --latest
|
||||
pnpm add -D wrangler@latest
|
||||
cd ..
|
||||
|
||||
cd e2e/ || exit 1
|
||||
npx --yes npm-check-updates@23.0.0 --upgrade
|
||||
npm install
|
||||
|
||||
PLAYWRIGHT_VERSION="$(node -p "require('./package.json').devDependencies['@playwright/test']")"
|
||||
EXPECTED_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
|
||||
if ! awk -v expected="${EXPECTED_IMAGE}" \
|
||||
'$1 == "FROM" && $2 == expected { found=1 } END { exit !found }' \
|
||||
Dockerfile.e2e; then
|
||||
echo "Dockerfile.e2e must use Playwright ${PLAYWRIGHT_VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ..
|
||||
|
||||
@@ -7,3 +7,4 @@ imap_port=11143
|
||||
# imap_tls_key=/path/to/key.pem
|
||||
# imap_cache_size=500
|
||||
# imap_http_timeout=30.0
|
||||
# imap_flag_db_path=data/imap_flags.db
|
||||
|
||||
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
||||
imap_tls_key: str = ""
|
||||
imap_cache_size: int = 500
|
||||
imap_http_timeout: float = 30.0
|
||||
imap_flag_db_path: str = "data/imap_flags.db"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
|
||||
@@ -12,3 +12,6 @@ services:
|
||||
- proxy_url=https://temp-email-api.xxx.xxx
|
||||
- port=8025
|
||||
- imap_port=11143
|
||||
volumes:
|
||||
# Persists IMAP flags (e.g. \Seen) across container restarts
|
||||
- ./data:/app/data
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
|
||||
|
||||
def _encode_flags(flags: set[str]) -> str:
|
||||
# JSON, not a comma-joined string: commas are legal inside IMAP keywords,
|
||||
# so "foo,bar" as a single keyword must not be split into two on read.
|
||||
return json.dumps(sorted(flags))
|
||||
|
||||
|
||||
def _decode_flags(text: str) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
return {str(flag) for flag in parsed}
|
||||
except ValueError:
|
||||
pass
|
||||
# Rows written before the switch to JSON were comma-joined.
|
||||
return set(text.split(","))
|
||||
|
||||
|
||||
class FlagStore:
|
||||
"""Persists IMAP message flags (e.g. \\Seen) to a local SQLite file.
|
||||
|
||||
SimpleMailbox previously kept flags only in an in-memory dict that was
|
||||
recreated from scratch for every IMAP connection (see SimpleRealm.requestAvatar
|
||||
in imap_server.py), so a client's STORE command (e.g. marking a message as
|
||||
read) was silently lost as soon as the session ended. This store gives
|
||||
flags a durable home keyed by (address, mailbox, uid) so they survive
|
||||
reconnects.
|
||||
"""
|
||||
|
||||
_UPSERT_SQL = """
|
||||
INSERT INTO imap_flags (address, mailbox, uid, flags)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(address, mailbox, uid) DO UPDATE SET flags = excluded.flags
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
# isolation_level=None: transactions are managed explicitly below, so
|
||||
# sqlite3 must not inject its own implicit BEGIN.
|
||||
return sqlite3.connect(self._db_path, timeout=10, isolation_level=None)
|
||||
|
||||
def _init_db(self):
|
||||
dirname = os.path.dirname(self._db_path)
|
||||
if dirname:
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS imap_flags (
|
||||
address TEXT NOT NULL,
|
||||
mailbox TEXT NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
flags TEXT NOT NULL,
|
||||
PRIMARY KEY (address, mailbox, uid)
|
||||
)
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_all(self, address: str, mailbox: str) -> dict[int, set[str]]:
|
||||
"""Return {uid: flags} for every UID with stored flags in a mailbox."""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT uid, flags FROM imap_flags WHERE address = ? AND mailbox = ?",
|
||||
(address, mailbox),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return {uid: _decode_flags(flags) for uid, flags in rows}
|
||||
|
||||
def update_flags(
|
||||
self,
|
||||
address: str,
|
||||
mailbox: str,
|
||||
uids: list[int],
|
||||
flags: set[str],
|
||||
mode: int,
|
||||
) -> dict[int, set[str]]:
|
||||
"""Atomically apply an IMAP STORE to the given UIDs.
|
||||
|
||||
mode follows twisted's convention: 1 adds (+FLAGS), -1 removes
|
||||
(-FLAGS), 0 replaces (FLAGS). Returns {uid: resulting flags}.
|
||||
|
||||
The read-modify-write runs inside one BEGIN IMMEDIATE transaction:
|
||||
two concurrent sessions that loaded the same stale in-memory flags
|
||||
cannot overwrite each other's STORE, because each session's update is
|
||||
recomputed from the row current at its own commit.
|
||||
"""
|
||||
if not uids:
|
||||
return {}
|
||||
flags = set(flags)
|
||||
result: dict[int, set[str]] = {}
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
placeholders = ",".join("?" for _ in uids)
|
||||
rows = conn.execute(
|
||||
"SELECT uid, flags FROM imap_flags"
|
||||
" WHERE address = ? AND mailbox = ?"
|
||||
f" AND uid IN ({placeholders})",
|
||||
(address, mailbox, *uids),
|
||||
).fetchall()
|
||||
current = {uid: _decode_flags(text) for uid, text in rows}
|
||||
upserts = []
|
||||
for uid in uids:
|
||||
old = current.get(uid, set())
|
||||
if mode == 1:
|
||||
new = old | flags
|
||||
elif mode == -1:
|
||||
new = old - flags
|
||||
else:
|
||||
new = set(flags)
|
||||
result[uid] = new
|
||||
upserts.append((address, mailbox, uid, _encode_flags(new)))
|
||||
conn.executemany(self._UPSERT_SQL, upserts)
|
||||
conn.execute("COMMIT")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
def set_flags_bulk(
|
||||
self, address: str, mailbox: str, uid_flags: dict[int, set[str]]
|
||||
) -> None:
|
||||
"""Upsert flags for multiple UIDs in a single transaction."""
|
||||
if not uid_flags:
|
||||
return
|
||||
rows = [
|
||||
(address, mailbox, uid, _encode_flags(flags))
|
||||
for uid, flags in uid_flags.items()
|
||||
]
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
conn.executemany(self._UPSERT_SQL, rows)
|
||||
conn.execute("COMMIT")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
_default_store: FlagStore | None = None
|
||||
_default_store_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_flag_store() -> FlagStore:
|
||||
"""Return the process-wide FlagStore, created lazily from settings."""
|
||||
global _default_store
|
||||
if _default_store is None:
|
||||
with _default_store_lock:
|
||||
if _default_store is None:
|
||||
from config import settings
|
||||
_default_store = FlagStore(settings.imap_flag_db_path)
|
||||
return _default_store
|
||||
@@ -3,11 +3,12 @@ import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
from twisted.internet import defer
|
||||
from twisted.internet import defer, threads
|
||||
from twisted.mail import imap4
|
||||
from zope.interface import implementer
|
||||
|
||||
from config import settings
|
||||
from flag_store import FlagStore, get_flag_store
|
||||
from imap_http_client import BackendClient
|
||||
from imap_message import SimpleMessage
|
||||
from parse_email import generate_email_model, parse_email, clean_raw_headers, fix_mojibake
|
||||
@@ -51,9 +52,12 @@ class MessageCache:
|
||||
@implementer(imap4.IMailboxInfo, imap4.IMailbox, imap4.ISearchableMailbox)
|
||||
class SimpleMailbox:
|
||||
|
||||
def __init__(self, name: str, client: BackendClient):
|
||||
def __init__(self, name: str, client: BackendClient, address: str,
|
||||
flag_store: FlagStore = None):
|
||||
self.name = name
|
||||
self._client = client
|
||||
self._address = address
|
||||
self._flag_store = flag_store if flag_store is not None else get_flag_store()
|
||||
self.listeners = []
|
||||
self.addListener = self.listeners.append
|
||||
self.removeListener = self.listeners.remove
|
||||
@@ -76,7 +80,10 @@ class SimpleMailbox:
|
||||
return 0
|
||||
|
||||
def getUnseenCount(self):
|
||||
return 0
|
||||
return sum(
|
||||
1 for u in self._uid_index
|
||||
if r"\Seen" not in self._flags.get(u, set())
|
||||
)
|
||||
|
||||
def isWriteable(self):
|
||||
return 1
|
||||
@@ -123,6 +130,7 @@ class SimpleMailbox:
|
||||
if count == 0:
|
||||
self._uid_index = []
|
||||
self._uid_index_built = True
|
||||
self._flags = {}
|
||||
return
|
||||
|
||||
uid_set = set()
|
||||
@@ -146,6 +154,11 @@ class SimpleMailbox:
|
||||
|
||||
self._uid_index = sorted(uid_set)
|
||||
self._uid_index_built = True
|
||||
# Load persisted flags (e.g. \Seen set by a previous IMAP session) so
|
||||
# STORE results survive reconnects instead of resetting every session.
|
||||
self._flags = yield threads.deferToThread(
|
||||
self._flag_store.get_all, self._address, self.name
|
||||
)
|
||||
_logger.info(
|
||||
"UID index built for %s: %d UIDs, range=%s..%s",
|
||||
self.name, len(self._uid_index),
|
||||
@@ -254,9 +267,10 @@ class SimpleMailbox:
|
||||
else:
|
||||
continue
|
||||
|
||||
if uid_val not in self._flags:
|
||||
self._flags[uid_val] = {r"\Seen"}
|
||||
flags = self._flags[uid_val]
|
||||
# Flags default to unseen (empty set) unless a previous
|
||||
# STORE persisted them via self._flag_store; self._flags
|
||||
# is refreshed from that store in _build_uid_index().
|
||||
flags = self._flags.get(uid_val, set())
|
||||
msg = SimpleMessage(
|
||||
uid_val, email_model, flags=flags, raw=raw,
|
||||
created_at=item.get("created_at"),
|
||||
@@ -309,18 +323,20 @@ class SimpleMailbox:
|
||||
return {}
|
||||
|
||||
target_uids = self._resolve_message_set(messages, uid)
|
||||
if not target_uids:
|
||||
return {}
|
||||
|
||||
# Apply the delta inside the store's own transaction instead of
|
||||
# computing new flag sets from this session's in-memory copy: another
|
||||
# concurrent session may have STOREd since we loaded, and basing the
|
||||
# write on stale state would silently drop its change.
|
||||
updated_flags = yield threads.deferToThread(
|
||||
self._flag_store.update_flags,
|
||||
self._address, self.name, target_uids, set(flags), mode,
|
||||
)
|
||||
|
||||
result = {}
|
||||
|
||||
for u in target_uids:
|
||||
current_flags = self._flags.get(u, set())
|
||||
|
||||
if mode == 1: # +FLAGS
|
||||
current_flags = current_flags | set(flags)
|
||||
elif mode == -1: # -FLAGS
|
||||
current_flags = current_flags - set(flags)
|
||||
elif mode == 0: # FLAGS (replace)
|
||||
current_flags = set(flags)
|
||||
|
||||
for u, current_flags in updated_flags.items():
|
||||
self._flags[u] = current_flags
|
||||
seq = self._uid_to_seq(u)
|
||||
if seq is not None:
|
||||
@@ -328,28 +344,58 @@ class SimpleMailbox:
|
||||
|
||||
return result
|
||||
|
||||
# SEARCH keys we can answer from persisted flag state, mapped to the flag
|
||||
# and whether it must be present. Anything else falls through to matching
|
||||
# every message, which is how this mailbox answered every query before
|
||||
# flags were persisted.
|
||||
_FLAG_SEARCH_KEYS = {
|
||||
"SEEN": ("\\Seen", True),
|
||||
"UNSEEN": ("\\Seen", False),
|
||||
"DELETED": ("\\Deleted", True),
|
||||
"UNDELETED": ("\\Deleted", False),
|
||||
"FLAGGED": ("\\Flagged", True),
|
||||
"UNFLAGGED": ("\\Flagged", False),
|
||||
"ANSWERED": ("\\Answered", True),
|
||||
"UNANSWERED": ("\\Answered", False),
|
||||
"DRAFT": ("\\Draft", True),
|
||||
"UNDRAFT": ("\\Draft", False),
|
||||
}
|
||||
|
||||
@defer.inlineCallbacks
|
||||
def search(self, query, uid):
|
||||
if not self._uid_index_built:
|
||||
yield self._build_uid_index()
|
||||
|
||||
results = []
|
||||
|
||||
predicates = []
|
||||
for term in query:
|
||||
if isinstance(term, str) and term.upper() == "ALL":
|
||||
if uid:
|
||||
results = list(self._uid_index)
|
||||
else:
|
||||
results = list(range(1, len(self._uid_index) + 1))
|
||||
break
|
||||
if isinstance(term, bytes):
|
||||
term = term.decode("ascii", "ignore")
|
||||
if not isinstance(term, str):
|
||||
continue
|
||||
predicate = self._FLAG_SEARCH_KEYS.get(term.upper())
|
||||
if predicate is not None:
|
||||
predicates.append(predicate)
|
||||
|
||||
if not results:
|
||||
if uid:
|
||||
results = list(self._uid_index)
|
||||
else:
|
||||
results = list(range(1, len(self._uid_index) + 1))
|
||||
matched = [
|
||||
u for u in self._uid_index
|
||||
if all(
|
||||
(flag in self._flags.get(u, set())) is present
|
||||
for flag, present in predicates
|
||||
)
|
||||
]
|
||||
|
||||
return results
|
||||
_logger.info(
|
||||
"SEARCH: uid=%s predicates=%d matched=%d/%d",
|
||||
uid, len(predicates), len(matched), len(self._uid_index),
|
||||
)
|
||||
|
||||
if uid:
|
||||
return matched
|
||||
|
||||
return [
|
||||
seq for seq in (self._uid_to_seq(u) for u in matched)
|
||||
if seq is not None
|
||||
]
|
||||
|
||||
def getUIDNext(self):
|
||||
if self._uid_index:
|
||||
|
||||
@@ -48,6 +48,49 @@ class SimpleIMAPServer(imap4.IMAP4Server):
|
||||
return real_write_seq(data)
|
||||
self.transport.writeSequence = logging_write_seq
|
||||
|
||||
@staticmethod
|
||||
def _fetch_implies_seen(query):
|
||||
"""Whether a FETCH query marks the messages it returns as read.
|
||||
|
||||
Per RFC 3501, ``BODY[...]`` sets ``\\Seen`` while ``BODY.PEEK[...]``
|
||||
does not. ``RFC822`` and ``RFC822.TEXT`` are defined as equivalents of
|
||||
``BODY[]`` / ``BODY[TEXT]`` and so also set it; ``RFC822.HEADER`` is
|
||||
equivalent to ``BODY.PEEK[HEADER]`` and does not.
|
||||
"""
|
||||
for part in query:
|
||||
part_type = getattr(part, "type", None)
|
||||
if part_type == "body" and not getattr(part, "peek", False):
|
||||
return True
|
||||
if part_type in ("rfc822", "rfc822text"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def do_FETCH(self, tag, messages, query, uid=0):
|
||||
"""Set ``\\Seen`` for non-peek fetches before delivering the response.
|
||||
|
||||
Twisted hands the parsed query to its own response builder and never
|
||||
passes it to the mailbox, so the mailbox alone cannot tell a ``BODY[]``
|
||||
from a ``BODY.PEEK[]``. Flag the messages here, before the FETCH
|
||||
response is generated, so the flags the client receives are current.
|
||||
"""
|
||||
if not query or not self._fetch_implies_seen(query):
|
||||
return imap4.IMAP4Server.do_FETCH(self, tag, messages, query, uid)
|
||||
|
||||
def _fetch(_):
|
||||
return imap4.IMAP4Server.do_FETCH(self, tag, messages, query, uid)
|
||||
|
||||
def _store_failed(failure):
|
||||
# A failure to persist \Seen must not cost the client its mail.
|
||||
_logger.warning("FETCH: could not set \\Seen: %s", failure.value)
|
||||
return None
|
||||
|
||||
d = defer.maybeDeferred(
|
||||
self.mbox.store, messages, ["\\Seen"], 1, uid
|
||||
)
|
||||
d.addErrback(_store_failed)
|
||||
d.addCallback(_fetch)
|
||||
return d
|
||||
|
||||
def _cbSelectWork(self, mbox, cmdName, tag):
|
||||
"""Override to add UIDNEXT in SELECT response (RFC 3501)."""
|
||||
if mbox is None:
|
||||
@@ -83,7 +126,7 @@ class Account(imap4.MemoryAccount):
|
||||
def _emptyMailbox(self, name, id):
|
||||
"""Return a dummy mailbox for CREATE requests (e.g. Gmail creating Drafts)."""
|
||||
_logger.debug("Accepting CREATE request for %s", name)
|
||||
return SimpleMailbox(name, self._client)
|
||||
return SimpleMailbox(name, self._client, self.name)
|
||||
|
||||
def create(self, pathspec):
|
||||
"""Accept CREATE silently without actually creating mailboxes."""
|
||||
@@ -111,8 +154,8 @@ class SimpleRealm:
|
||||
|
||||
client = BackendClient(password)
|
||||
|
||||
inbox = SimpleMailbox("INBOX", client)
|
||||
sent = SimpleMailbox("SENT", client)
|
||||
inbox = SimpleMailbox("INBOX", client, username)
|
||||
sent = SimpleMailbox("SENT", client, username)
|
||||
|
||||
account = Account(username)
|
||||
account._client = client
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
aiosmtpd==1.4.6
|
||||
pydantic-settings==2.13.1
|
||||
Twisted==25.5.0
|
||||
pydantic-settings==2.14.2
|
||||
Twisted==26.4.0
|
||||
httpx==0.28.1
|
||||
pyOpenSSL==26.0.0
|
||||
service-identity==24.2.0
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Regression test for GH issue #1074: IMAP STORE could not mark mail as read.
|
||||
|
||||
Root cause (see 1074-report.md for the full investigation):
|
||||
- smtp_proxy_server/imap_server.py SimpleRealm.requestAvatar() builds a brand
|
||||
new SimpleMailbox (with an empty in-memory `_flags` dict) on every IMAP
|
||||
login/connection.
|
||||
- smtp_proxy_server/imap_mailbox.py SimpleMailbox.store() only ever wrote
|
||||
flag changes into that in-memory dict, and
|
||||
SimpleMailbox._fetch_and_cache_messages() unconditionally defaulted every
|
||||
message's flags to `{\\Seen}`.
|
||||
- Net effect: every message always looked "already read", and a client's
|
||||
STORE +FLAGS (\\Seen) command appeared to succeed but was silently
|
||||
discarded the moment the IMAP session ended (e.g. Thunderbird reconnecting
|
||||
to poll), which matches the reported "cannot mark mail as read".
|
||||
|
||||
This test drives SimpleMailbox directly (no Docker / no live worker
|
||||
backend) against a FakeBackendClient, and verifies that:
|
||||
1. A never-touched message starts unseen (not hardcoded \\Seen).
|
||||
2. STORE +FLAGS (\\Seen) is visible to a brand new SimpleMailbox instance
|
||||
pointed at the same flag_store.FlagStore, simulating a client
|
||||
disconnect + reconnect.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from twisted.internet import defer
|
||||
from twisted.mail import imap4
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
import imap_mailbox
|
||||
from flag_store import FlagStore
|
||||
from imap_mailbox import SimpleMailbox
|
||||
from imap_server import SimpleIMAPServer
|
||||
|
||||
|
||||
def _sync_defer_to_thread(f, *args, **kwargs):
|
||||
"""Stand-in for twisted.internet.threads.deferToThread.
|
||||
|
||||
imap_mailbox.py offloads FlagStore's blocking sqlite calls to a thread
|
||||
via deferToThread. Tests don't run a reactor to service that thread
|
||||
pool, so this executes the callable inline and wraps the result as an
|
||||
already-fired Deferred instead.
|
||||
"""
|
||||
try:
|
||||
result = f(*args, **kwargs)
|
||||
except Exception:
|
||||
return defer.fail()
|
||||
return defer.succeed(result)
|
||||
|
||||
|
||||
def _run(d):
|
||||
"""Extract the result of a Deferred that fires synchronously."""
|
||||
box = []
|
||||
d.addBoth(box.append)
|
||||
assert box, "Deferred did not fire synchronously"
|
||||
value = box[0]
|
||||
if isinstance(value, Failure):
|
||||
value.raiseException()
|
||||
return value
|
||||
|
||||
|
||||
class FakeBackendClient:
|
||||
"""Minimal stand-in for imap_http_client.BackendClient.
|
||||
|
||||
Serves a fixed in-memory list of message rows so tests don't need a
|
||||
live worker backend or Docker.
|
||||
"""
|
||||
|
||||
def __init__(self, messages):
|
||||
self._messages = messages
|
||||
|
||||
def get_message_count(self, mailbox_name):
|
||||
return defer.succeed(len(self._messages))
|
||||
|
||||
def get_messages(self, mailbox_name, limit, offset):
|
||||
page = self._messages[offset:offset + limit]
|
||||
count = len(self._messages) if offset == 0 else None
|
||||
return defer.succeed((page, count))
|
||||
|
||||
|
||||
class _MailboxTestBase(unittest.TestCase):
|
||||
"""Shared fixture: a temp-dir FlagStore and an inline deferToThread."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmpdir = tempfile.mkdtemp()
|
||||
self._db_path = os.path.join(self._tmpdir, "flags.db")
|
||||
self._orig_deferToThread = imap_mailbox.threads.deferToThread
|
||||
imap_mailbox.threads.deferToThread = _sync_defer_to_thread
|
||||
|
||||
def tearDown(self):
|
||||
imap_mailbox.threads.deferToThread = self._orig_deferToThread
|
||||
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
||||
|
||||
def _make_mailbox(self, messages, address="user@example.com"):
|
||||
flag_store = FlagStore(self._db_path)
|
||||
client = FakeBackendClient(messages)
|
||||
return SimpleMailbox("INBOX", client, address, flag_store=flag_store)
|
||||
|
||||
|
||||
class ImapMarkAsSeenPersistenceTest(_MailboxTestBase):
|
||||
|
||||
def test_new_message_starts_unseen(self):
|
||||
mbox = self._make_mailbox([{"id": 1, "raw": ""}])
|
||||
_run(mbox._build_uid_index())
|
||||
self.assertEqual(mbox.getUnseenCount(), 1)
|
||||
|
||||
def test_store_seen_survives_reconnect(self):
|
||||
messages = [{"id": 1, "raw": ""}, {"id": 2, "raw": ""}]
|
||||
|
||||
# --- Session 1: client connects, selects INBOX, sees 2 unseen ---
|
||||
mbox1 = self._make_mailbox(messages)
|
||||
_run(mbox1._build_uid_index())
|
||||
self.assertEqual(mbox1.getUnseenCount(), 2)
|
||||
|
||||
# Client sends: UID STORE 1 +FLAGS (\Seen)
|
||||
message_set = imap4.MessageSet(1, 1)
|
||||
result = _run(mbox1.store(message_set, [r"\Seen"], mode=1, uid=True))
|
||||
self.assertIn(1, [seq for seq in result])
|
||||
self.assertEqual(mbox1.getUnseenCount(), 1)
|
||||
|
||||
# --- Session 2: client disconnects and reconnects (new SimpleMailbox
|
||||
# instance, exactly like SimpleRealm.requestAvatar creates per login) ---
|
||||
mbox2 = self._make_mailbox(messages)
|
||||
_run(mbox2._build_uid_index())
|
||||
|
||||
self.assertEqual(
|
||||
mbox2.getUnseenCount(), 1,
|
||||
"flag set via STORE in a previous session was lost on reconnect",
|
||||
)
|
||||
self.assertIn(r"\Seen", mbox2._flags.get(1, set()))
|
||||
self.assertNotIn(r"\Seen", mbox2._flags.get(2, set()))
|
||||
|
||||
def test_store_minus_flags_removes_seen(self):
|
||||
messages = [{"id": 1, "raw": ""}]
|
||||
mbox = self._make_mailbox(messages)
|
||||
_run(mbox._build_uid_index())
|
||||
|
||||
message_set = imap4.MessageSet(1, 1)
|
||||
_run(mbox.store(message_set, [r"\Seen"], mode=1, uid=True))
|
||||
self.assertEqual(mbox.getUnseenCount(), 0)
|
||||
|
||||
_run(mbox.store(message_set, [r"\Seen"], mode=-1, uid=True))
|
||||
self.assertEqual(mbox.getUnseenCount(), 1)
|
||||
|
||||
# The removal must also land in SQLite, not just this instance's
|
||||
# in-memory dict: a fresh mailbox (= reconnect) must see it unseen.
|
||||
mbox2 = self._make_mailbox(messages)
|
||||
_run(mbox2._build_uid_index())
|
||||
self.assertEqual(
|
||||
mbox2.getUnseenCount(), 1,
|
||||
"-FLAGS (\\Seen) was not persisted across reconnect",
|
||||
)
|
||||
self.assertNotIn(r"\Seen", mbox2._flags.get(1, set()))
|
||||
|
||||
def test_keyword_containing_comma_round_trips(self):
|
||||
# Commas are legal in IMAP keywords; "foo,bar" is ONE keyword and
|
||||
# must not come back from storage split into "foo" and "bar".
|
||||
messages = [{"id": 1, "raw": ""}]
|
||||
mbox = self._make_mailbox(messages)
|
||||
_run(mbox._build_uid_index())
|
||||
|
||||
message_set = imap4.MessageSet(1, 1)
|
||||
_run(mbox.store(message_set, ["foo,bar"], mode=1, uid=True))
|
||||
|
||||
mbox2 = self._make_mailbox(messages)
|
||||
_run(mbox2._build_uid_index())
|
||||
self.assertEqual(mbox2._flags.get(1), {"foo,bar"})
|
||||
|
||||
def test_concurrent_sessions_do_not_clobber_each_other(self):
|
||||
# Two sessions load the same (empty) flags, then each STOREs a
|
||||
# different flag on the same message. The second write must not be
|
||||
# computed from its stale in-memory copy, or the first flag is lost.
|
||||
messages = [{"id": 1, "raw": ""}]
|
||||
mbox1 = self._make_mailbox(messages)
|
||||
mbox2 = self._make_mailbox(messages)
|
||||
_run(mbox1._build_uid_index())
|
||||
_run(mbox2._build_uid_index())
|
||||
|
||||
message_set = imap4.MessageSet(1, 1)
|
||||
_run(mbox1.store(message_set, [r"\Seen"], mode=1, uid=True))
|
||||
_run(mbox2.store(message_set, [r"\Flagged"], mode=1, uid=True))
|
||||
|
||||
mbox3 = self._make_mailbox(messages)
|
||||
_run(mbox3._build_uid_index())
|
||||
self.assertEqual(
|
||||
mbox3._flags.get(1), {r"\Seen", r"\Flagged"},
|
||||
"a session's +FLAGS was lost to a concurrent session's stale write",
|
||||
)
|
||||
|
||||
|
||||
class ImapSearchFlagTest(_MailboxTestBase):
|
||||
"""SEARCH must answer flag keys from persisted state.
|
||||
|
||||
SimpleMailbox declares ISearchableMailbox, so IMAP4Server.do_SEARCH hands
|
||||
the whole query to it and never runs its own search_UNSEEN/search_SEEN
|
||||
helpers. Before this, every SEARCH fell through to "return everything",
|
||||
so `SEARCH UNSEEN` listed mail the user had already read.
|
||||
"""
|
||||
|
||||
def _seen_mailbox(self):
|
||||
"""Two messages, UID 1 marked \\Seen, UID 2 left unread."""
|
||||
messages = [{"id": 1, "raw": ""}, {"id": 2, "raw": ""}]
|
||||
mbox = self._make_mailbox(messages)
|
||||
_run(mbox._build_uid_index())
|
||||
_run(mbox.store(imap4.MessageSet(1, 1), [r"\Seen"], mode=1, uid=True))
|
||||
return mbox
|
||||
|
||||
def test_search_unseen_excludes_seen_messages(self):
|
||||
mbox = self._seen_mailbox()
|
||||
self.assertEqual(_run(mbox.search(["UNSEEN"], uid=True)), [2])
|
||||
|
||||
def test_search_seen_returns_only_seen_messages(self):
|
||||
mbox = self._seen_mailbox()
|
||||
self.assertEqual(_run(mbox.search(["SEEN"], uid=True)), [1])
|
||||
|
||||
def test_search_unseen_returns_sequence_numbers_when_not_uid(self):
|
||||
mbox = self._seen_mailbox()
|
||||
self.assertEqual(_run(mbox.search(["UNSEEN"], uid=False)), [2])
|
||||
|
||||
def test_search_all_still_returns_everything(self):
|
||||
mbox = self._seen_mailbox()
|
||||
self.assertEqual(_run(mbox.search(["ALL"], uid=True)), [1, 2])
|
||||
|
||||
def test_search_accepts_bytes_terms(self):
|
||||
mbox = self._seen_mailbox()
|
||||
self.assertEqual(_run(mbox.search([b"UNSEEN"], uid=True)), [2])
|
||||
|
||||
def test_unsupported_search_key_still_matches_everything(self):
|
||||
# Keys this mailbox cannot evaluate keep the previous lenient
|
||||
# behaviour rather than silently returning an empty result.
|
||||
mbox = self._seen_mailbox()
|
||||
self.assertEqual(_run(mbox.search(["SINCE"], uid=True)), [1, 2])
|
||||
|
||||
def test_combined_keys_are_conjunctive(self):
|
||||
mbox = self._seen_mailbox()
|
||||
_run(mbox.store(imap4.MessageSet(2, 2), [r"\Flagged"], mode=1, uid=True))
|
||||
self.assertEqual(_run(mbox.search(["UNSEEN", "FLAGGED"], uid=True)), [2])
|
||||
self.assertEqual(_run(mbox.search(["SEEN", "FLAGGED"], uid=True)), [])
|
||||
|
||||
|
||||
class FetchImpliesSeenTest(unittest.TestCase):
|
||||
"""Only non-peek body fetches may set \\Seen (RFC 3501 §6.4.5).
|
||||
|
||||
Twisted passes the parsed FETCH query to its own response builder and
|
||||
never to the mailbox, so SimpleMailbox alone cannot distinguish BODY[]
|
||||
from BODY.PEEK[]. SimpleIMAPServer.do_FETCH makes that call instead.
|
||||
"""
|
||||
|
||||
def _implies_seen(self, command):
|
||||
parser = imap4._FetchParser()
|
||||
parser.parseString(command)
|
||||
return SimpleIMAPServer._fetch_implies_seen(parser.result)
|
||||
|
||||
def test_body_sets_seen(self):
|
||||
self.assertTrue(self._implies_seen(b"BODY[]"))
|
||||
|
||||
def test_body_section_sets_seen(self):
|
||||
# BODY[HEADER] is a non-peek fetch and does set \Seen, unlike the
|
||||
# RFC822.HEADER shorthand below.
|
||||
self.assertTrue(self._implies_seen(b"BODY[HEADER]"))
|
||||
|
||||
def test_body_peek_does_not_set_seen(self):
|
||||
self.assertFalse(self._implies_seen(b"BODY.PEEK[]"))
|
||||
|
||||
def test_rfc822_sets_seen(self):
|
||||
self.assertTrue(self._implies_seen(b"RFC822"))
|
||||
|
||||
def test_rfc822_text_sets_seen(self):
|
||||
self.assertTrue(self._implies_seen(b"RFC822.TEXT"))
|
||||
|
||||
def test_rfc822_header_does_not_set_seen(self):
|
||||
# Defined as equivalent to BODY.PEEK[HEADER].
|
||||
self.assertFalse(self._implies_seen(b"RFC822.HEADER"))
|
||||
|
||||
def test_metadata_only_fetch_does_not_set_seen(self):
|
||||
self.assertFalse(self._implies_seen(b"FLAGS"))
|
||||
self.assertFalse(self._implies_seen(b"UID"))
|
||||
self.assertFalse(self._implies_seen(b"RFC822.SIZE"))
|
||||
|
||||
def test_mixed_query_sets_seen_if_any_part_does(self):
|
||||
self.assertTrue(self._implies_seen(b"(FLAGS BODY[])"))
|
||||
self.assertFalse(self._implies_seen(b"(FLAGS BODY.PEEK[])"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -141,9 +141,9 @@ ENABLE_AUTO_REPLY = false
|
||||
# FRONTEND_URL = "https://xxxx.xxx"
|
||||
# Enable check junk mail
|
||||
# ENABLE_CHECK_JUNK_MAIL = false
|
||||
# junk mail check list, if status exists and status is not pass, will be marked as junk mail
|
||||
# JUNK_MAIL_CHECK_LIST = = ["spf", "dkim", "dmarc"]
|
||||
# junk mail force check pass list, if no status or status is not pass, will be marked as junk mail
|
||||
# junk mail check list: reject registered failure/error results; none and SPF/DKIM neutral are treated as absent
|
||||
# JUNK_MAIL_CHECK_LIST = ["spf", "dkim", "dmarc"]
|
||||
# junk mail force pass list: every configured method must explicitly return pass
|
||||
# JUNK_MAIL_FORCE_PASS_LIST = ["spf", "dkim", "dmarc"]
|
||||
# remove attachment if size exceed 2MB, mail maybe mising some information due to parsing
|
||||
# REMOVE_EXCEED_SIZE_ATTACHMENT = true
|
||||
|
||||
@@ -48,6 +48,12 @@ No additional env var is required.
|
||||
|
||||
Register at `https://resend.com/domains` and add DNS records according to the instructions.
|
||||
|
||||
> [!WARNING] DNS record proxy status on Cloudflare
|
||||
> Resend domain verification CNAME records **must be set to DNS-only** (gray cloud) in
|
||||
> the Cloudflare DNS dashboard. Proxied (orange cloud) records will prevent Resend from
|
||||
> completing verification, and a single failed attempt can take several hours before
|
||||
> Retry becomes available. See [#515](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515).
|
||||
|
||||
Create an `api key` on the `API KEYS` page.
|
||||
|
||||
Then execute the following command to add `RESEND_TOKEN` to secrets:
|
||||
@@ -144,12 +150,30 @@ cd worker
|
||||
wrangler secret put SMTP_CONFIG
|
||||
```
|
||||
|
||||
## User Accounts, Email Addresses, and Send Permission
|
||||
|
||||
These concepts are related, but they identify different objects:
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| User account | Signs in to the user center and can bind and manage multiple email addresses. The user account's login email is not automatically a sending or receiving address |
|
||||
| Email address | The identity that actually receives and sends mail, such as `name@example.com`. The address currently selected on the frontend home page is authenticated by its Address JWT |
|
||||
| Send permission and balance | Permission and balance records in `address_sender` are managed independently **per email address**; user roles configured by `NO_LIMIT_SEND_ROLE` can bypass address balance checks |
|
||||
|
||||
Multiple email addresses bound to the same user account therefore do not share send permission or balance. To request permission for an address:
|
||||
|
||||
1. Switch to the email address that needs to send mail
|
||||
2. Open the **Send Mail** page
|
||||
3. Click **Request Access**
|
||||
|
||||
The request affects only the address selected at that time. After switching to another address, check that address's own balance and request permission separately if needed. An email address can also hold send permission without being bound to a user account.
|
||||
|
||||
## Send Balance Mechanism
|
||||
|
||||
Users need a send balance to send emails. The balance mechanism works as follows:
|
||||
An email address needs its own send balance to send emails. The balance mechanism works as follows:
|
||||
|
||||
1. **Auto-initialize Default Quota**: When `DEFAULT_SEND_BALANCE > 0`, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time
|
||||
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review
|
||||
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, switch to the target email address in the frontend and click **Request Access** to create a pending send-access record for the current address
|
||||
3. **Unlimited Sending**: The following methods can bypass balance checks:
|
||||
- Add the address to the "No Limit Send Address List" in the admin console
|
||||
- Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits
|
||||
|
||||
@@ -64,6 +64,7 @@ services:
|
||||
| `imap_tls_key` | empty | IMAP TLS private key file path (PEM) |
|
||||
| `imap_cache_size` | `500` | Max cached messages per mailbox |
|
||||
| `imap_http_timeout` | `30.0` | Backend HTTP request timeout (seconds) |
|
||||
| `imap_flag_db_path` | `data/imap_flags.db` | SQLite file storing IMAP flags (e.g. `\Seen`) so `STORE` (mark as read) survives client reconnects. Mount a volume over its parent directory so it persists across container restarts |
|
||||
|
||||
## Enabling STARTTLS
|
||||
|
||||
|
||||
@@ -19,6 +19,52 @@ res = requests.get(
|
||||
|
||||
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
|
||||
|
||||
## Mail State API
|
||||
|
||||
After running the database migration, `ENABLE_MAIL_READ_STATUS` and `ENABLE_MAIL_FLAGGED` can be enabled independently. The former adds `unread` to mail responses and the latter adds `flagged`. State lives in a separate sparse relation table without changing `raw_mails`; historical mail without a state record is read and unstarred by default, and the backend handles state calculation and updates.
|
||||
|
||||
Read status is the high-write feature: it inserts one unread row for every new mail and deletes that row when the mail becomes read. Enabling only Flagged performs none of those writes; the database changes only when a user adds or removes a star.
|
||||
|
||||
With an Address JWT, use `GET /api/mail-states` to retrieve the available read states. The frontend uses each returned `value` directly for filtering and updates, and displays its `label_key`.
|
||||
|
||||
Use `PATCH /api/mails/state` to move the state of up to 100 mail IDs:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<your-worker-address>/api/mails/state",
|
||||
headers={"Authorization": "Bearer <your-JWT-password>"},
|
||||
json={"ids": [1, 2], "state": "read"}
|
||||
)
|
||||
```
|
||||
|
||||
With a User JWT, use `GET /user_api/mail-states` and `PATCH /user_api/mails/state`. Only mail belonging to addresses bound to that user can be changed. The response contains the updated `unread` state.
|
||||
|
||||
Flagged is independent of read state. Use `PATCH /api/mails/flagged` to add or remove stars:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<your-worker-address>/api/mails/flagged",
|
||||
headers={"Authorization": "Bearer <your-JWT-password>"},
|
||||
json={"ids": [1, 2], "flagged": True}
|
||||
)
|
||||
```
|
||||
|
||||
The User JWT equivalent is `PATCH /user_api/mails/flagged`.
|
||||
|
||||
Mail-list endpoints accept a state `value` returned by the backend. For example, list unread mail with:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread
|
||||
```
|
||||
|
||||
Use `flagged=true` to list starred mail. It can be combined with `mail_state`:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread&flagged=true
|
||||
```
|
||||
|
||||
`/user_api/mails` accepts the same parameter.
|
||||
|
||||
## Admin Mail API
|
||||
|
||||
Supports `address` filter
|
||||
@@ -49,6 +95,27 @@ print(response.json())
|
||||
|
||||
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
|
||||
|
||||
## Admin Get Mail API
|
||||
|
||||
Fetch a single mail by mail ID without a mailbox JWT. Authenticate with `x-admin-auth`.
|
||||
The response matches one entry returned by `/admin/mails`: gzip-compressed raw content is decompressed into `raw`, and `raw_blob` is excluded.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
mail_id = 1
|
||||
url = f"https://<your-worker-address>/admin/mails/{mail_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<your-Admin-password>",
|
||||
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## Admin Delete Mail API
|
||||
|
||||
Delete a single mail by mail ID.
|
||||
@@ -139,6 +206,36 @@ This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/re
|
||||
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
|
||||
:::
|
||||
|
||||
### Bound Address List
|
||||
|
||||
`GET /user_api/bind_address` uses server-side pagination and accepts these query parameters:
|
||||
|
||||
Requests without pagination parameters return the default first page. Fetching all bound addresses in one request is not supported.
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `limit` | `20` | Page size, from 1 to 100 |
|
||||
| `offset` | `0` | Pagination offset |
|
||||
|
||||
The `results` array contains only the current page. The total is queried only when `offset=0`; later pages return `count: 0`, so clients should retain the total from the first page.
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "https://<your-worker-address>/user_api/bind_address"
|
||||
headers = {
|
||||
"x-user-token": "<your-user-JWT-token>",
|
||||
}
|
||||
querystring = {
|
||||
"limit": "20",
|
||||
"offset": "0",
|
||||
}
|
||||
response = requests.get(url, headers=headers, params=querystring)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
### User Mail List
|
||||
|
||||
Supports `address` filter
|
||||
|
||||
```python
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## Send Email via HTTP API
|
||||
|
||||
There are two HTTP API endpoints for sending emails:
|
||||
There are three HTTP API endpoints for sending emails:
|
||||
|
||||
| Endpoint | Authentication | Use Case |
|
||||
|----------|---------------|----------|
|
||||
| `/api/send_mail` | `Authorization: Bearer <address_JWT>` header | Internal calls, requires cookie / header auth |
|
||||
| `/external/api/send_mail` | `token` field in request body | External system integration, no header auth needed |
|
||||
| `/user_api/address/:address_id/send_mail` | `x-user-token: <user_JWT>` header | Signed-in users sending from one of their bound addresses |
|
||||
|
||||
::: tip What is "Address JWT"?
|
||||
The Address JWT is the `jwt` field returned when creating an email address via `/api/new_address` or `/admin/new_address`.
|
||||
@@ -59,6 +60,44 @@ res = requests.post(
|
||||
)
|
||||
```
|
||||
|
||||
### Method 3: User JWT (`/user_api/address/:address_id/send_mail`)
|
||||
|
||||
Obtain `address_id` from the paginated `GET /user_api/bind_address` response. The backend verifies that the address belongs to the current user; clients cannot choose an arbitrary sender address.
|
||||
|
||||
If the site grants unlimited sending to the current user's role through `NO_LIMIT_SEND_ROLE`, also send the `access_token` returned by `GET /user_api/settings`. The frontend handles this token automatically.
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
"from_name": "Sender Name",
|
||||
"to_name": "Recipient Name",
|
||||
"to_mail": "Recipient Address",
|
||||
"subject": "Email Subject",
|
||||
"is_html": False,
|
||||
"content": "Email content",
|
||||
}
|
||||
|
||||
res = requests.post(
|
||||
"https://your_worker_domain/user_api/address/123/send_mail",
|
||||
json=send_body,
|
||||
headers={
|
||||
"x-user-token": "<user_JWT>",
|
||||
# "x-user-access-token": "<user_access_token>", # Required for role permissions
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The same user-address API group also provides:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/user_api/address/:address_id/settings` | Get the address and remaining send balance |
|
||||
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | Request send access for the address |
|
||||
| `GET` | `/user_api/sendbox?limit=20&offset=0&address=optional-address` | List the current user's sent items, optionally filtered by a bound address |
|
||||
| `DELETE` | `/user_api/sendbox/:mail_id` | Delete one sent item owned by the current user |
|
||||
|
||||
All endpoints require a User JWT. Address-scoped endpoints verify that `address_id` is bound to the current user, while user-level sent-item endpoints only return or delete records for the user's bound addresses. The user access token is only used to apply optional role permissions.
|
||||
|
||||
## Send Email via SMTP
|
||||
|
||||
Please first refer to [Configure SMTP Proxy](/en/guide/feature/config-smtp-proxy.html).
|
||||
|
||||
@@ -27,7 +27,7 @@ RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||
RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
```
|
||||
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow optional random second-level subdomains
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow random or manually entered subdomains
|
||||
- `RANDOM_SUBDOMAIN_LENGTH`: random string length, range `1-63`, default `8`
|
||||
|
||||
The create-address APIs only generate a random subdomain when the request explicitly passes
|
||||
@@ -47,6 +47,10 @@ the request body:
|
||||
If you want to create an address under a specific subdomain such as `team.abc.com`, do not pass
|
||||
`enableRandomSubdomain: true`; use the direct-subdomain flow below instead.
|
||||
|
||||
For base domains in `RANDOM_SUBDOMAIN_DOMAINS`, the web and admin pages offer **Normal Domain**,
|
||||
**Use Random Subdomain**, and **Use Custom Subdomain** as single-choice modes. In custom mode,
|
||||
enter only `team`; the frontend combines it as `team.abc.com`.
|
||||
|
||||
> [!NOTE]
|
||||
> This feature only appends a random second-level subdomain when the mailbox is created.
|
||||
>
|
||||
@@ -77,10 +81,10 @@ If you want to create an address under a specific subdomain such as `team.abc.co
|
||||
>
|
||||
> Reference issue: [#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
|
||||
|
||||
## Let APIs Specify Subdomains Directly
|
||||
## Let APIs Specify Other Subdomains Directly
|
||||
|
||||
If you do not want the system to generate a random subdomain, and instead want the caller to
|
||||
explicitly create addresses like `team.abc.com`, enable:
|
||||
If a base domain is not in `RANDOM_SUBDOMAIN_DOMAINS`, but API callers still need to create
|
||||
addresses like `team.abc.com` directly, enable:
|
||||
|
||||
```toml
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
@@ -93,7 +97,7 @@ addresses can be created through `/api/new_address` or `/admin/new_address`:
|
||||
- `name@dev.team.abc.com`
|
||||
|
||||
> [!NOTE]
|
||||
> This only relaxes the domain validation used by the create-address APIs. It does not change the
|
||||
> default domain dropdown, and it does not create Cloudflare-side subdomain mail routes for you.
|
||||
> This switch only relaxes create-address API domain validation. It does not change the frontend
|
||||
> domain scope or create Cloudflare-side subdomain mail routes for you.
|
||||
>
|
||||
> If the admin panel has already saved an override once, you can switch it back to **Follow Environment Variable** to clear the override and return to env fallback behavior.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Star History
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
|
||||
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | Text/JSON | Whether to allow create-address APIs to use base-domain suffix matching. When enabled, if `example.com` is allowed, `/api/new_address` and `/admin/new_address` can also accept `foo.example.com` or `a.b.example.com` | `true` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow optional random subdomain creation, so `name@abc.com` can become `name@<random>.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow random or manual subdomains; random mode can turn `name@abc.com` into `name@<random>.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` |
|
||||
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
|
||||
@@ -50,8 +50,8 @@
|
||||
> [!NOTE]
|
||||
> When `DEFAULT_DOMAINS` is unset or configured as an empty array, it falls back to `DOMAINS`.
|
||||
>
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` only controls automatic random subdomain generation during mailbox
|
||||
> creation. It does not create Cloudflare-side subdomain routing for you.
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` defines the base-domain scope shared by the frontend random and manual
|
||||
> subdomain modes. It does not create Cloudflare-side subdomain routing for you.
|
||||
>
|
||||
> To actually receive mail on addresses like `name@<random>.abc.com`, **you must add a wildcard
|
||||
> `*` MX record under the base domain in DNS** by copying the apex's existing MX records to
|
||||
@@ -65,9 +65,8 @@
|
||||
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
|
||||
> domain.
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is different from random subdomain generation: it lets
|
||||
> API callers **directly specify** a subdomain such as `foo.example.com`, while random subdomain
|
||||
> generation appends one automatically during creation.
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is independent from those frontend modes. It lets API
|
||||
> callers **directly specify** subdomains such as `foo.example.com` under other allowed base domains.
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` precedence: if the env is explicitly set to `false`, the
|
||||
> feature is globally forced off; otherwise the persisted admin setting takes precedence, and the env
|
||||
@@ -97,14 +96,19 @@
|
||||
| ------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||
| `BLACK_LIST` | Text | Blacklist for filtering senders, comma separated | `gov.cn,edu.cn` |
|
||||
| `ENABLE_CHECK_JUNK_MAIL` | Text/JSON | Whether to enable junk mail checking, used with the following two lists | `false` |
|
||||
| `JUNK_MAIL_CHECK_LIST` | JSON | Junk mail check configuration, marked as junk if any item `exists` and `fails` | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | Junk mail check configuration, marked as junk if any item `does not exist` or `fails` | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_CHECK_LIST` | JSON | Existence check; registered failure/error results are junk, while `none` and SPF/DKIM `neutral` are treated as absent | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | Strict pass check; every item must explicitly return `pass`, otherwise it is treated as junk | `["spf", "dkim", "dmarc"]` |
|
||||
| `FORWARD_ADDRESS_LIST` | JSON | Global forward address list, disabled if not configured, all emails will be forwarded to listed addresses when enabled | `["xxx@xxx.com"]` |
|
||||
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
|
||||
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
|
||||
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
|
||||
| `ENABLE_MAIL_READ_STATUS` | Text/JSON | Enables web read/unread state. It writes one unread row for every new mail and deletes that row when read, so this is the high-write feature. Historical mail without a row is read. **Run the database migration before enabling.** | `true` |
|
||||
| `ENABLE_MAIL_FLAGGED` | Text/JSON | Independently enables Flagged/starred mail. It writes only when a user adds or removes a star and does not add per-new-mail writes. Historical mail is unstarred. **Run the database migration before enabling.** | `true` |
|
||||
| `CLEANUP_BATCH_SIZE` | Number | Per-run limit for mail, sent-mail, and creation/activity-based address cleanup. Defaults to `3000`, valid range `1-5000`. Smaller values reduce per-run D1 pressure; larger values clear backlogs faster | `3000` |
|
||||
|
||||
> [!NOTE]
|
||||
> Authentication results follow their standards: SPF `none` means no usable domain or SPF record was found, and SPF `neutral` must be treated like `none`; DKIM `none` means the message was unsigned, and DKIM `neutral` is also treated as unsigned; DMARC `none` means no applicable DMARC policy was found. Unregistered results and unsupported method versions are ignored. `JUNK_MAIL_CHECK_LIST` treats these results as absent, while `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
|
||||
>
|
||||
> `ENABLE_MAIL_GZIP` adds CPU cost for gzip compression on write and decompression on read. Free-tier Workers are more likely to hit CPU limits, so a paid plan is recommended before enabling it
|
||||
>
|
||||
> `Junk mail checking` and `attachment removal` require email parsing, free tier CPU is limited, may cause large email parsing timeout
|
||||
|
||||
@@ -48,6 +48,12 @@ send_email = [
|
||||
|
||||
注册 `https://resend.com/domains` 根据提示添加 DNS 记录,
|
||||
|
||||
> [!WARNING] Cloudflare 上 DNS 记录的代理状态
|
||||
> Resend 域名验证的 CNAME 记录**必须设置为 仅 DNS**(灰云),在
|
||||
> Cloudflare DNS 控制面板中代理(橙云)记录会阻止 Resend
|
||||
> 完成验证,且一次失败的尝试可能需要数小时才能重试。
|
||||
> 参见 [#515](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515)。
|
||||
|
||||
`API KEYS` 页面创建 `api key`
|
||||
|
||||
然后执行下面的命令,将 `RESEND_TOKEN` 添加到 secrets 中
|
||||
@@ -144,12 +150,30 @@ cd worker
|
||||
wrangler secret put SMTP_CONFIG
|
||||
```
|
||||
|
||||
## 用户账号、邮箱地址与发信权限
|
||||
|
||||
这三个概念相互关联,但不是同一个对象:
|
||||
|
||||
| 概念 | 说明 |
|
||||
|------|------|
|
||||
| 用户账号 | 用于登录用户中心,可绑定和管理多个邮箱地址;用户账号的登录邮箱本身不等于用于收发信的邮箱地址 |
|
||||
| 邮箱地址 | 实际收信和发信的身份,例如 `name@example.com`;前端首页当前选中的地址由该地址的凭证(Address JWT)认证 |
|
||||
| 发信权限与额度 | `address_sender` 中的权限和额度以**邮箱地址**为单位独立管理;`NO_LIMIT_SEND_ROLE` 配置的用户角色可以绕过地址额度检查 |
|
||||
|
||||
因此,同一用户账号绑定的多个邮箱地址不会共享发信权限或余额。为某个地址申请发信权限时:
|
||||
|
||||
1. 先在前端切换到需要发信的邮箱地址
|
||||
2. 打开「发送邮件」页
|
||||
3. 点击「申请权限」
|
||||
|
||||
申请只作用于当时选中的邮箱地址。切换到另一个地址后,需要查看该地址自己的额度,并在需要时单独申请。邮箱地址不必绑定用户账号也可以独立持有发信权限。
|
||||
|
||||
## 发信余额机制
|
||||
|
||||
用户发送邮件需要有发信余额。余额机制如下:
|
||||
邮箱地址发送邮件需要有该地址的发信余额。余额机制如下:
|
||||
|
||||
1. **自动初始化默认额度**:当 `DEFAULT_SEND_BALANCE > 0` 时,用户打开前端发信页或第一次调用发信接口时,系统会自动为该地址初始化默认额度
|
||||
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,用户仍可以在前端界面点击「申请发信权限」按钮,创建待管理员处理的发信权限记录
|
||||
2. **手动申请**:如果 `DEFAULT_SEND_BALANCE = 0`,使用者可以在前端切换到目标邮箱地址并点击「申请权限」,为当前地址创建待管理员处理的发信权限记录
|
||||
3. **无限制发送**:以下方式可以跳过余额检查:
|
||||
- 在 admin 后台将地址加入「无限制发送地址列表」
|
||||
- 配置 `NO_LIMIT_SEND_ROLE` 环境变量,指定可以无限发送的用户角色
|
||||
|
||||
@@ -64,6 +64,7 @@ services:
|
||||
| `imap_tls_key` | 空 | IMAP TLS 私钥文件路径(PEM) |
|
||||
| `imap_cache_size` | `500` | 每个邮箱的消息缓存上限 |
|
||||
| `imap_http_timeout` | `30.0` | 后端 HTTP 请求超时时间(秒) |
|
||||
| `imap_flag_db_path` | `data/imap_flags.db` | 存储 IMAP 标志(如 `\Seen`)的 SQLite 文件路径,使 `STORE`(标记已读)在客户端断线重连后依然生效。建议挂载卷到其所在目录以在容器重启后保留 |
|
||||
|
||||
## 启用 STARTTLS
|
||||
|
||||
|
||||
@@ -19,6 +19,52 @@ res = requests.get(
|
||||
|
||||
**注意**:`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject`、`text`、`html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm`、`postal-mime`)。
|
||||
|
||||
## 邮件状态 API
|
||||
|
||||
完成数据库迁移后,可分别启用 `ENABLE_MAIL_READ_STATUS` 和 `ENABLE_MAIL_FLAGGED`。前者让邮件响应包含 `unread`,后者包含 `flagged`;两个开关互不依赖。邮件状态保存在独立的稀疏关联表中,不修改 `raw_mails`;没有状态记录的历史邮件默认已读且未星标,状态计算和更新全部由后端处理。
|
||||
|
||||
已读状态是高写入量功能:启用后每封新邮件会新增一条未读记录,邮件变为已读时再删除。仅启用星标不会执行这些写入,只有用户添加或取消星标时才修改数据库。
|
||||
|
||||
地址 JWT 使用 `GET /api/mail-states` 获取当前可用的已读状态。前端直接使用其中的 `value` 作为筛选和更新参数,并使用 `label_key` 显示名称。
|
||||
|
||||
使用 `PATCH /api/mails/state` 批量移动邮件状态,每次最多传入 100 个邮件 ID:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<你的worker地址>/api/mails/state",
|
||||
headers={"Authorization": "Bearer <你的JWT密码>"},
|
||||
json={"ids": [1, 2], "state": "read"}
|
||||
)
|
||||
```
|
||||
|
||||
用户 JWT 使用 `GET /user_api/mail-states` 和 `PATCH /user_api/mails/state`,只能修改该用户已绑定地址的邮件。接口返回更新后的 `unread` 状态。
|
||||
|
||||
星标与已读状态相互独立。使用 `PATCH /api/mails/flagged` 添加或取消星标:
|
||||
|
||||
```python
|
||||
requests.patch(
|
||||
"https://<你的worker地址>/api/mails/flagged",
|
||||
headers={"Authorization": "Bearer <你的JWT密码>"},
|
||||
json={"ids": [1, 2], "flagged": True}
|
||||
)
|
||||
```
|
||||
|
||||
用户 JWT 对应接口为 `PATCH /user_api/mails/flagged`。
|
||||
|
||||
邮件列表使用后端返回的状态 `value` 查询。例如查询未读邮件:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread
|
||||
```
|
||||
|
||||
使用 `flagged=true` 查询星标邮件,并可与 `mail_state` 组合:
|
||||
|
||||
```text
|
||||
GET /api/mails?limit=20&offset=0&mail_state=unread&flagged=true
|
||||
```
|
||||
|
||||
`/user_api/mails` 支持相同参数。
|
||||
|
||||
## admin 邮件 API
|
||||
|
||||
支持 `address` 过滤
|
||||
@@ -49,6 +95,27 @@ print(response.json())
|
||||
|
||||
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
|
||||
|
||||
## admin 获取单封邮件 API
|
||||
|
||||
无需邮箱 JWT,通过邮件 ID 获取单封邮件,并使用 `x-admin-auth` 认证。
|
||||
返回结构与 `/admin/mails` 中的单条记录一致:gzip 压缩的原始邮件会解压到 `raw`,响应不包含 `raw_blob`。
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
mail_id = 1
|
||||
url = f"https://<你的worker地址>/admin/mails/{mail_id}"
|
||||
|
||||
headers = {
|
||||
"x-admin-auth": "<你的Admin密码>",
|
||||
# "x-custom-auth": "<你的网站密码>", # 如果启用了私有站点密码
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## admin 删除邮件 API
|
||||
|
||||
通过邮件 ID 删除单封邮件。
|
||||
@@ -139,6 +206,36 @@ 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
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
## 通过 HTTP API 发送邮件
|
||||
|
||||
有两种 HTTP API 端点可以发送邮件,区别如下:
|
||||
有三种 HTTP API 端点可以发送邮件,区别如下:
|
||||
|
||||
| 端点 | 认证方式 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| `/api/send_mail` | `Authorization: Bearer <地址JWT>` header | 内部调用,需要先通过 cookie / header 鉴权 |
|
||||
| `/external/api/send_mail` | 请求体中的 `token` 字段 | 外部系统集成,无需 header 鉴权 |
|
||||
| `/user_api/address/:address_id/send_mail` | `x-user-token: <用户JWT>` header | 已登录用户使用自己的绑定邮箱发信 |
|
||||
|
||||
::: tip 什么是"地址 JWT"?
|
||||
地址 JWT 是通过 `/api/new_address` 或 `/admin/new_address` 创建邮箱地址时返回的 `jwt` 字段。
|
||||
@@ -59,6 +60,44 @@ res = requests.post(
|
||||
)
|
||||
```
|
||||
|
||||
### 方式三:使用用户 JWT(`/user_api/address/:address_id/send_mail`)
|
||||
|
||||
`address_id` 可从分页接口 `GET /user_api/bind_address` 的结果中获取。后端会验证该地址属于当前用户,客户端不能自行指定发件邮箱。
|
||||
|
||||
如果站点通过 `NO_LIMIT_SEND_ROLE` 为当前用户角色配置了无限发信额度,还需要传入 `GET /user_api/settings` 返回的 `access_token`。前端会自动处理该令牌。
|
||||
|
||||
```python
|
||||
send_body = {
|
||||
"from_name": "发件人名字",
|
||||
"to_name": "收件人名字",
|
||||
"to_mail": "收件人地址",
|
||||
"subject": "邮件主题",
|
||||
"is_html": False,
|
||||
"content": "邮件内容",
|
||||
}
|
||||
|
||||
res = requests.post(
|
||||
"https://你的worker域名/user_api/address/123/send_mail",
|
||||
json=send_body,
|
||||
headers={
|
||||
"x-user-token": "<用户JWT>",
|
||||
# "x-user-access-token": "<用户访问令牌>", # 使用角色权限时需要
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
同一组用户地址接口还包括:
|
||||
|
||||
| 方法 | 端点 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/user_api/address/:address_id/settings` | 获取地址和剩余发信额度 |
|
||||
| `POST` | `/user_api/address/:address_id/request_send_mail_access` | 为该地址申请发信权限 |
|
||||
| `GET` | `/user_api/sendbox?limit=20&offset=0&address=可选地址` | 分页获取当前用户的发件箱,可按绑定地址过滤 |
|
||||
| `DELETE` | `/user_api/sendbox/:mail_id` | 删除当前用户的一条发件记录 |
|
||||
|
||||
以上接口都需要用户 JWT。地址级接口验证 `address_id` 是否绑定到当前用户,用户级发件箱接口只返回或删除当前用户绑定地址的记录;用户访问令牌仅用于应用可选的角色权限。
|
||||
|
||||
## 通过 SMTP 发送邮件
|
||||
|
||||
请先参考 [配置 SMTP 代理](/zh/guide/feature/config-smtp-proxy.html)。
|
||||
|
||||
@@ -26,7 +26,7 @@ RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
|
||||
RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
```
|
||||
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`:允许启用随机二级域名的基础域名列表
|
||||
- `RANDOM_SUBDOMAIN_DOMAINS`:允许使用随机或手动子域名的基础域名列表
|
||||
- `RANDOM_SUBDOMAIN_LENGTH`:随机串长度,范围 `1-63`,默认 `8`
|
||||
|
||||
创建地址 API 需要显式传入 `enableRandomSubdomain: true` 才会生成随机二级域名。前端勾选“启用随机二级域名”时会自动传这个字段;如果你自己调用 `/api/new_address` 或 `/admin/new_address`,也需要在请求体中传入:
|
||||
@@ -41,6 +41,9 @@ RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
|
||||
`domain` 必须传 `RANDOM_SUBDOMAIN_DOMAINS` 中配置的基础域名,例如 `abc.com`。如果要创建 `team.abc.com` 这种指定子域名地址,请不要传 `enableRandomSubdomain: true`,而是使用下方“直接指定子域名”的流程。
|
||||
|
||||
对于 `RANDOM_SUBDOMAIN_DOMAINS` 中的基础域名,网页端和管理后台会提供“普通域名”、
|
||||
“启用随机子域名”和“使用自定义子域名”三种单选模式。自定义模式只需输入 `team`,前端会组合成 `team.abc.com`。
|
||||
|
||||
> [!NOTE]
|
||||
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
|
||||
>
|
||||
@@ -58,10 +61,10 @@ RANDOM_SUBDOMAIN_LENGTH = 8
|
||||
>
|
||||
> 参考 issue:[#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
|
||||
|
||||
## 允许 API 直接指定子域名
|
||||
## 允许 API 直接指定其他子域名
|
||||
|
||||
如果你不想让系统随机生成子域名,而是希望调用方在创建地址时直接指定 `team.abc.com` 这种子域名,
|
||||
可以开启:
|
||||
如果基础域名不在 `RANDOM_SUBDOMAIN_DOMAINS` 中,但仍希望 API 调用方直接指定
|
||||
`team.abc.com` 这种子域名,可以开启:
|
||||
|
||||
```toml
|
||||
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
@@ -75,7 +78,6 @@ ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
|
||||
都可以通过 `/api/new_address` 或 `/admin/new_address` 创建。
|
||||
|
||||
> [!NOTE]
|
||||
> 这个能力只放宽“创建地址 API 的域名校验”,不会改动默认域名下拉,也不会自动创建 Cloudflare 侧的
|
||||
> 子域名邮箱路由。
|
||||
> 这个开关只放宽创建地址 API 的域名校验,不会改动前端的域名范围,也不会自动创建 Cloudflare 侧的子域名邮箱路由。
|
||||
>
|
||||
> 如果你在管理后台里保存过这个开关,后续也可以通过“跟随环境变量”把它恢复到未设置状态,再重新回退到 env 默认值。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Star History
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
|
||||
</picture>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true,当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
|
||||
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | 文本/JSON | 是否允许创建邮箱 API 使用“基础域名后缀匹配”。开启后,如果允许域名里有 `example.com`,则 `/api/new_address` 与 `/admin/new_address` 可以接受 `foo.example.com`、`a.b.example.com` 这类子域名 | `true` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | 允许启用随机子域名的基础域名列表,启用后可把 `name@abc.com` 创建成 `name@随机串.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | 允许使用随机或手动子域名的基础域名列表,随机模式可把 `name@abc.com` 创建成 `name@随机串.abc.com` | `["abc.com"]` |
|
||||
| `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` |
|
||||
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
|
||||
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/`) | `true` |
|
||||
@@ -50,8 +50,8 @@
|
||||
> [!NOTE]
|
||||
> `DEFAULT_DOMAINS` 未配置或配置为空数组时,会回退使用 `DOMAINS`。
|
||||
>
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare
|
||||
> 侧的子域名路由。
|
||||
> `RANDOM_SUBDOMAIN_DOMAINS` 定义前端随机及手动子域名模式共同使用的基础域名范围,不会自动帮你
|
||||
> 创建 Cloudflare 侧的子域名路由。
|
||||
>
|
||||
> 要让 `name@<随机>.abc.com` 这种随机子域名地址真的能收到邮件,**必须在基础域名的 DNS 中为
|
||||
> `*` 子域添加通配 MX 记录**:把基础域名上现有的每一条 MX 记录都复制到 `*` 主机名上,
|
||||
@@ -62,8 +62,8 @@
|
||||
>
|
||||
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与随机子域名功能不同:它允许 API 调用方**直接指定**
|
||||
> `foo.example.com` 这类子域名;而随机子域名功能是系统在创建时自动补一个随机前缀。
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与上述前端模式独立:它允许 API 调用方在其他允许的
|
||||
> 基础域名下**直接指定** `foo.example.com` 这类子域名。
|
||||
>
|
||||
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 的优先级为:当 env 明确设置为 `false` 时,全局硬禁用;
|
||||
> 其他情况下优先使用后台持久化设置,后台未设置时再回退到 env 值。
|
||||
@@ -91,14 +91,19 @@
|
||||
| ------------------------------- | --------- | -------------------------------------------------------------------------- | -------------------------- |
|
||||
| `BLACK_LIST` | 文本 | 黑名单,用于过滤发件人,逗号分隔 | `gov.cn,edu.cn` |
|
||||
| `ENABLE_CHECK_JUNK_MAIL` | 文本/JSON | 是否启用垃圾邮件检查,配合下列两个列表使用 | `false` |
|
||||
| `JUNK_MAIL_CHECK_LIST` | JSON | 垃圾邮件检查配置, 任何一项 `存在` 且 `不通过` 则被判定为垃圾邮件 | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | 垃圾邮件检查配置, 任何一项 `不存在` 或者 `不通过` 则被判定为垃圾邮件 | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_CHECK_LIST` | JSON | 存在性检查;已注册的失败/错误结果判定为垃圾邮件,`none` 和 SPF/DKIM `neutral` 按不存在处理 | `["spf", "dkim", "dmarc"]` |
|
||||
| `JUNK_MAIL_FORCE_PASS_LIST` | JSON | 强制通过检查;每一项都必须明确返回 `pass`,否则判定为垃圾邮件 | `["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` |
|
||||
| `ENABLE_MAIL_READ_STATUS` | 文本/JSON | 启用网页已读/未读功能。每封新邮件写入一条未读记录,变为已读时删除,因此属于高写入量功能。无记录的历史邮件默认已读。**启用前必须先执行数据库迁移。** | `true` |
|
||||
| `ENABLE_MAIL_FLAGGED` | 文本/JSON | 独立启用星标邮件功能。只有添加或取消星标时才写数据库,不会产生逐封新邮件写入;历史邮件默认未星标。**启用前必须先执行数据库迁移。** | `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.9.0",
|
||||
"version": "1.12.0",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^26.2.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"wrangler": "^4.96.0"
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vitepress dev docs",
|
||||
|
||||
Generated
+733
-699
File diff suppressed because it is too large
Load Diff
+9
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.9.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,23 +11,23 @@
|
||||
"build": "wrangler deploy --dry-run --outdir dist --minify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260602.1",
|
||||
"@cloudflare/workers-types": "^5.20260819.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.5",
|
||||
"eslint": "9.39.1",
|
||||
"globals": "^16.5.0",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"wrangler": "^4.96.0"
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"wrangler": "^4.124.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.888.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.888.0",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"hono": "^4.12.23",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"hono": "^4.13.3",
|
||||
"jsonpath-plus": "^10.4.0",
|
||||
"mimetext": "^3.0.28",
|
||||
"postal-mime": "^2.7.4",
|
||||
"resend": "^6.12.4",
|
||||
"postal-mime": "^2.7.6",
|
||||
"resend": "^6.20.0",
|
||||
"telegraf": "4.16.3",
|
||||
"worker-mailer": "^1.2.1"
|
||||
},
|
||||
|
||||
Generated
+947
-917
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { Jwt } from 'hono/utils/jwt'
|
||||
import i18n from '../i18n'
|
||||
import { getBooleanValue } from '../utils'
|
||||
import { newAddress, handleListQuery } from '../common'
|
||||
import { deleteRawMails, prepareRawMailDeleteStatements } from '../mail_flags'
|
||||
|
||||
const listAddresses = async (c: Context<HonoCustomType>) => {
|
||||
const { limit, offset, query, sort_by, sort_order } = c.req.query();
|
||||
@@ -70,38 +71,51 @@ const createNewAddress = async (c: Context<HonoCustomType>) => {
|
||||
const deleteAddress = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success } = await c.env.DB.prepare(
|
||||
`DELETE FROM address WHERE id = ? `
|
||||
).bind(id).run();
|
||||
// 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([
|
||||
...prepareRawMailDeleteStatements(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (select name from address where id = ?)`,
|
||||
[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);
|
||||
if (!success) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
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
|
||||
})
|
||||
return c.json({ success })
|
||||
};
|
||||
|
||||
const clearInbox = async (c: Context<HonoCustomType>) => {
|
||||
const msgs = i18n.getMessagesbyContext(c);
|
||||
const { id } = c.req.param();
|
||||
const { success: mailSuccess } = await c.env.DB.prepare(
|
||||
`DELETE FROM raw_mails WHERE address IN`
|
||||
+ ` (select name from address where id = ?) `
|
||||
).bind(id).run();
|
||||
const { success: mailSuccess } = await deleteRawMails(
|
||||
c.env.DB,
|
||||
c.env,
|
||||
`address IN (select name from address where id = ?)`,
|
||||
[id],
|
||||
);
|
||||
if (!mailSuccess) {
|
||||
return c.text(msgs.OperationFailedMsg, 500)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Context } from "hono";
|
||||
import { handleMailListQuery } from "../common";
|
||||
import { resolveRawEmailRow } from "../gzip";
|
||||
import { deleteRawMails } from "../mail_flags";
|
||||
|
||||
export default {
|
||||
getMails: async (c: Context<HonoCustomType>) => {
|
||||
@@ -24,11 +26,17 @@ 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(
|
||||
`DELETE FROM raw_mails WHERE id = ? `
|
||||
).bind(id).run();
|
||||
const { success } = await deleteRawMails(c.env.DB, c.env, `id = ?`, [id]);
|
||||
return c.json({
|
||||
success: success
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getJsonSetting, saveSetting } from '../utils';
|
||||
import { CleanupSettings, CustomSqlCleanup } from '../models';
|
||||
import i18n from '../i18n';
|
||||
import { LocaleMessages } from '../i18n/type';
|
||||
import { cleanupOrphanMailFlags } from '../mail_flags';
|
||||
|
||||
// SQL validation error types
|
||||
type SqlValidationError = 'empty' | 'too_long' | 'not_delete' | 'multiple_statements' | 'has_comments';
|
||||
@@ -84,6 +85,7 @@ export const executeCustomSqlCleanup = async (
|
||||
console.log(`Executing custom SQL cleanup [${customSql.name}]: ${sql}`);
|
||||
const result = await c.env.DB.prepare(sql).run();
|
||||
const rowsAffected = result.meta?.changes ?? 0;
|
||||
await cleanupOrphanMailFlags(c.env.DB, c.env);
|
||||
console.log(`Custom SQL cleanup [${customSql.name}] completed, rows affected: ${rowsAffected}`);
|
||||
return { success: true, rowsAffected };
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Context } from "hono";
|
||||
import { getSetting, saveSetting } from "../utils";
|
||||
|
||||
const CONFIG_KEY_PREFIX = "admin-config:";
|
||||
const CONFIG_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
||||
|
||||
const getStorageKey = (key: string) => `${CONFIG_KEY_PREFIX}${key}`;
|
||||
|
||||
export default {
|
||||
get: async (c: Context<HonoCustomType>) => {
|
||||
const key = c.req.param("key");
|
||||
if (!CONFIG_KEY_PATTERN.test(key)) {
|
||||
return c.text("Invalid config key", 400);
|
||||
}
|
||||
|
||||
const value = await getSetting(c, getStorageKey(key));
|
||||
return c.json({ key, value });
|
||||
},
|
||||
save: async (c: Context<HonoCustomType>) => {
|
||||
const { key, value } = await c.req.json<{ key?: unknown, value?: unknown }>();
|
||||
if (typeof key !== "string" || !CONFIG_KEY_PATTERN.test(key)) {
|
||||
return c.text("Invalid config key", 400);
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return c.text("Config value must be a string", 400);
|
||||
}
|
||||
|
||||
await saveSetting(c, getStorageKey(key), value);
|
||||
return c.json({ success: true, key, value });
|
||||
},
|
||||
}
|
||||
@@ -20,6 +20,15 @@ CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mail_flags (
|
||||
mail_id INTEGER NOT NULL,
|
||||
address_id INTEGER NOT NULL,
|
||||
flag INTEGER NOT NULL,
|
||||
PRIMARY KEY (mail_id, flag)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mail_flags_address_flag_mail ON mail_flags(address_id, flag, mail_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS address (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
@@ -219,11 +228,13 @@ export default {
|
||||
},
|
||||
getVersion: async (c: Context<HonoCustomType>) => {
|
||||
const version = await utils.getSetting(c, CONSTANTS.DB_VERSION_KEY);
|
||||
const sizeResult = await c.env.DB.prepare("SELECT 1").run();
|
||||
return c.json({
|
||||
need_initialization: !version,
|
||||
need_migration: version && version != CONSTANTS.DB_VERSION,
|
||||
current_db_version: version,
|
||||
code_db_version: CONSTANTS.DB_VERSION
|
||||
code_db_version: CONSTANTS.DB_VERSION,
|
||||
database_size: sizeResult.meta.size_after ?? null
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import db_api from './db_api'
|
||||
import ip_blacklist_settings from './ip_blacklist_settings'
|
||||
import ai_extract_settings from './ai_extract_settings'
|
||||
import e2e_test_api from './e2e_test_api'
|
||||
import config_api from './config_api'
|
||||
|
||||
export const api = new Hono<HonoCustomType>()
|
||||
|
||||
@@ -33,6 +34,7 @@ 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
|
||||
@@ -95,6 +97,10 @@ api.get('admin/db_version', db_api.getVersion)
|
||||
api.post('admin/db_initialize', db_api.initialize)
|
||||
api.post('admin/db_migration', db_api.migrate)
|
||||
|
||||
// generic admin config
|
||||
api.get('/admin/config/:key', config_api.get)
|
||||
api.post('/admin/config', config_api.save)
|
||||
|
||||
// IP blacklist settings
|
||||
api.get('/admin/ip_blacklist/settings', ip_blacklist_settings.getIpBlacklistSettings)
|
||||
api.post('/admin/ip_blacklist/settings', ip_blacklist_settings.saveIpBlacklistSettings)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user