mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-08-28 03:28:43 +08:00
Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4911b3ec05 | ||
|
|
e5c3c7bf71 | ||
|
|
2299c56040 | ||
|
|
624fc9bb96 | ||
|
|
12152fc893 | ||
|
|
a09ede8944 | ||
|
|
f9281818e9 | ||
|
|
5553c6484a | ||
|
|
d04c1a865d | ||
|
|
116ddc7324 | ||
|
|
2dcbad40ad | ||
|
|
95badf5aed | ||
|
|
b3666c0900 | ||
|
|
e499211197 | ||
|
|
342fe22e4f | ||
|
|
8c883b269f | ||
|
|
4c1e593d07 | ||
|
|
7eaa3b3b8e | ||
|
|
0581632b59 | ||
|
|
4ce22ef249 | ||
|
|
99b332345b | ||
|
|
565bb839db | ||
|
|
dbd1f8706d | ||
|
|
3f1d800e90 | ||
|
|
70b30c2494 | ||
|
|
1a1dd720c8 | ||
|
|
2d501d82cf | ||
|
|
7c57592742 | ||
|
|
41105ed803 | ||
|
|
c924b71a5c | ||
|
|
4f0b44de2e | ||
|
|
f6fcbe793c | ||
|
|
05a8ebb590 | ||
|
|
b7718100c5 | ||
|
|
bf786947e3 | ||
|
|
cfb31807f1 | ||
|
|
308fbe2f9a | ||
|
|
44b29aa646 | ||
|
|
2221342560 | ||
|
|
add0124cfd | ||
|
|
8324b133fb | ||
|
|
74c8e8f7e4 | ||
|
|
2df4de22d9 | ||
|
|
437db7c050 | ||
|
|
dd294037ab | ||
|
|
a9e2c89246 | ||
|
|
72bbfe8fd6 | ||
|
|
796a5e4ac5 | ||
|
|
347be5c762 |
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -1 +0,0 @@
|
||||
../../skills/cf-temp-mail-agent-mail
|
||||
@@ -1 +0,0 @@
|
||||
../../.claude/skills/cf-temp-mail-upgrade-dependencies
|
||||
@@ -1 +0,0 @@
|
||||
../../.claude/skills/cf-temp-mail-version-upgrade
|
||||
@@ -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}
|
||||
```
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
@@ -40,11 +40,17 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
cd vitepress-docs/
|
||||
wget https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip -O docs/public/ui_install/frontend.zip
|
||||
pnpm install --no-frozen-lockfile
|
||||
TAG_NAME=$(gh release view --json tagName --jq '.tagName')
|
||||
if TAG_NAME=$(gh release view --json tagName --jq '.tagName' 2>/dev/null); then
|
||||
echo "Using release tag $TAG_NAME"
|
||||
else
|
||||
TAG_NAME="${WORKFLOW_RUN_HEAD_BRANCH:-${GITHUB_REF_NAME:-main}}"
|
||||
echo "No GitHub release found for this repo; fallback TAG_NAME=$TAG_NAME"
|
||||
fi
|
||||
echo "Deploying docs for tag $TAG_NAME"
|
||||
export TAG_NAME
|
||||
pnpm run deploy
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- uses: pnpm/action-setup@v5
|
||||
name: Install pnpm
|
||||
|
||||
@@ -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/
|
||||
|
||||
+77
-1
@@ -6,7 +6,83 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.8.0(main)
|
||||
## v1.11.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend Next| 新增 `frontend-next` Vite React + shadcn 命令生成组件的非 admin 邮件客户端,按参考稿 B 风格实现创建/恢复地址、收件箱、写信、地址管理、用户账号集成、设置与亮色/暗色切换
|
||||
|
||||
- docs: |前端/文档| 统一邮箱地址、用户账号与 Admin 权限的表述,并明确发信权限和额度按邮箱地址独立申请与管理
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103)
|
||||
|
||||
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103)
|
||||
|
||||
- feat: |Worker| 邮件、发件箱及按创建/活跃时间清理地址时改为分批处理,默认每次最多 3000 条并支持通过 `CLEANUP_BATCH_SIZE` 调整(上限 5000),减少单次扫描和删除量(issue #1103)
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试
|
||||
- fix: |E2E| 新增清理批次上限、后续批次继续执行、保留未过期数据及地址关联数据清理测试
|
||||
|
||||
## v1.10.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| 新增 `GET /admin/mails/:id` 接口,支持管理员按邮件 ID 跨邮箱读取单封邮件,并兼容 gzip 压缩存储(issue #1096)
|
||||
- feat: |Frontend| 邮箱新增「邮箱全宽列表视图」开关(在外观设置中控制),开启后默认全宽列表展示邮件标题与正文预览,点击单封邮件再展开为双栏,再次点击同一封邮件可回到列表视图;多选模式下点击邮件会同步切换勾选状态与右侧预览,并禁用同邮件点击收回列表,展开时双栏左侧列表宽度仍遵循「邮箱双栏视图左侧列表宽度占比」配置;默认关闭,保留原有双栏行为
|
||||
- feat: |Frontend| 邮箱全宽列表视图新增「正文预览行数」配置(在外观设置中控制),可设置邮件正文预览的最大行数,默认 2 行,0 表示关闭预览
|
||||
- feat: |Frontend| 外观设置新增「自动加载邮件正文中的外部图片」开关,关闭后邮件预览(含全屏视图)会先经 DOMPurify 消毒,并以白名单策略处理所有可能发起请求的位置:仅保留可证明为本地的引用(`cid:`、`data:image/`、`blob:` 与站内相对路径),其余一律阻断;`base`、`meta`、`script`、`link`、`iframe`、`object`、`embed`、`noscript` 等会自行取用资源或改变解析基准的元素在此模式下移除,`<style>` 保留但其中 `url()`、`image-set()`、`@import` 的远端引用会被替换。正文上方显示已阻断资源数量的提示条,可一键按封加载;默认保持开启,行为与此前一致(issue #1073)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `<a>` 与 `<area>` 的外部导航链接,并阻断通过 CSS 转义函数名或 at-rule 绕过远程资源过滤的情况
|
||||
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
|
||||
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测:SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
|
||||
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
|
||||
- fix: |AI 提取| 强化提示词,要求 AI 保持邮件原始链接域名,避免小模型改写验证链接域名导致错误跳转(issue #1072)
|
||||
- fix: |AI 提取| HTML-only 邮件在发送给 Workers AI 前会先压缩为可读文本,避免样式模板过长导致验证码位于 4000 字截断之后而无法识别
|
||||
- fix: |Frontend| 移动端 Header 增加页头内边距,避免标题、菜单按钮与屏幕边缘过近
|
||||
- fix: |IMAP 代理| 修复 IMAP `STORE` 无法真正标记邮件已读的问题:邮件不再硬编码为 `\Seen`,且 `SimpleMailbox` 的 flags 变更现持久化到本地 SQLite(新增 `imap_flag_db_path` 配置),使已读/未读状态可在客户端断线重连(如 Thunderbird 轮询)后保留,而非每次新建连接即丢失(issue #1074)
|
||||
- fix: |IMAP 代理| 修复 `SEARCH UNSEEN` 返回全部邮件的问题:`SimpleMailbox.search()` 现按持久化的 flags 计算 `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` 及其否定形式,多个条件按 AND 组合;无法识别的检索条件仍沿用原有行为返回全部邮件
|
||||
- fix: |IMAP 代理| 修复取信不会自动标记已读的问题:`BODY[...]`、`RFC822`、`RFC822.TEXT` 取信现按 RFC 3501 自动置 `\Seen`,而 `BODY.PEEK[...]`、`RFC822.HEADER` 及仅取元数据(如 `FLAGS`)不会
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |Worker| 新增 junk_mail_policy 回归测试(issue #1084):覆盖 SPF/DKIM/DMARC 的 `none`/`neutral` 按认证方法不存在处理、明确 `fail` 仍被拒收,以及 `JUNK_MAIL_FORCE_PASS_LIST` 仅接受明确 `pass`
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |README| 新增完整日文 README,并在中文和英文 README 中添加日文导航链接
|
||||
- feat: |Frontend| 「邮箱双栏视图左侧列表宽度占比」最小值由 0.25 放宽至 0,左侧列表可完全折叠使正文近乎全屏,刻度增加 0 点;收件箱与发件箱的双栏拆分同步生效,并优化外观设置文案以明确该比例控制左侧邮件列表宽度
|
||||
|
||||
## v1.9.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI 识别| 未配置 Workers AI 绑定时,自动回退到内置正则提取验证码(支持中英日韩,并排除年份与 `YYYYMMDD` 日期误判),让无 Workers AI 的自部署用户也能在 Telegram 推送与 Webhook 中拿到验证码
|
||||
- feat: |Telegram| Telegram 新邮件推送与 `/mails` 历史邮件查看支持展示 AI 提取结果,包含验证码、验证链接、服务链接、订阅链接等关键信息
|
||||
- feat: |Webhook| 邮件 Webhook 模板支持填充 AI 提取结果占位符,包括 `aiExtractType`、`aiExtractResult`、`aiExtractResultText`
|
||||
- feat: |Frontend| 新增 `DISABLE_SHOW_GITHUB_FOR_USER` 配置,可仅对普通用户隐藏 Header 的 GitHub/版本入口,admin 仍可见(issue #1041)
|
||||
- feat: |Frontend| 将邮箱地址凭证弹窗升级为“地址凭证与连接方式”,复用普通用户与 admin 创建邮箱结果弹窗;支持通过 `ENABLE_AGENT_EMAIL_INFO` 展示 AI Agent 接入信息,并通过 `SMTP_IMAP_PROXY_CONFIG` 展示 SMTP/IMAP 客户端连接信息
|
||||
- docs: |随机子域名| 在前端“启用随机子域名”提示与 `subdomain` / `worker-vars` 文档(中英)中明确说明:要让 `name@<随机>.abc.com` 真正收到邮件,必须在基础域名 DNS 中为 `*` 子域添加通配 MX 记录,Email Routing 子域不继承父域配置(issue #1035)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码
|
||||
- fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段
|
||||
- fix: |Address| 统一规范化配置域名、收件地址域名与前缀的空白和大小写,覆盖 `DOMAINS`、`DEFAULT_DOMAINS`、`USER_ROLES.domains`、随机子域名、转发规则、SMTP 与 `SEND_MAIL` 域名匹配,保留转发规则空域名 catch-all 行为,并明确空 `DEFAULT_DOMAINS` / 角色域名回退到 `DOMAINS` 的行为,避免大小写配置或入站收件域名导致创建、收件、转发或发信失败(issue #926)
|
||||
- fix: |AI 提取| 将 AI 邮件识别默认 Workers AI 模型切换为支持 JSON Mode 且未弃用的 `@cf/meta/llama-3.1-8b-instruct-fast`,并在文档中补充 `@cf/zai-org/glm-4.7-flash` 结构化输出兼容性提示(issue #1029)
|
||||
- fix: |CI| 将 GitHub Actions 与 e2e Docker 镜像统一升级到 Node.js 24,适配 Wrangler 4.90.0 的运行时要求
|
||||
- fix: |Frontend| 修复 iOS Safari 点击输入框时因移动端表单控件字号过小导致页面自动放大的问题
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.8.0
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
+77
-1
@@ -6,7 +6,83 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.8.0(main)
|
||||
## v1.11.0(main)
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend Next| Add a `frontend-next` Vite React non-admin mail client using shadcn CLI-generated components in the reference B style, covering address create/restore, inbox, compose, address management, user account integration, settings, and light/dark theme support
|
||||
|
||||
- docs: |Frontend/Docs| Clarify mailbox address, user account, and Admin permission terminology, including address-specific send access and balances
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
|
||||
- fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103)
|
||||
|
||||
- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103)
|
||||
|
||||
- feat: |Worker| Process mail, sent-mail, and creation/activity-based address cleanup in batches of 3000 by default, configurable through `CLEANUP_BATCH_SIZE` up to 5000, reducing per-run scans and deletes (issue #1103)
|
||||
|
||||
### Testing
|
||||
|
||||
- fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps
|
||||
- fix: |E2E| Cover cleanup batch limits, continuation on later runs, preservation of recent data, and address-related data cleanup
|
||||
|
||||
## v1.10.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Admin| Add `GET /admin/mails/:id` for administrators to fetch a single mail by ID across mailboxes, including gzip-compressed storage support (issue #1096)
|
||||
- feat: |Frontend| Add a "Full-width mailbox list view" toggle in Appearance settings. When enabled, the mailbox shows a full-width list of subjects and body previews by default; clicking a mail expands it into the two-pane split view, clicking the same mail again returns to the list view; in multi-select mode, clicking a mail updates both its checked state and the right-side preview while disabling same-mail collapse, and the split width still follows the "Left list width in two-column mailbox view" setting. Defaults to off, preserving the original two-pane behavior
|
||||
- feat: |Frontend| Add "Body Preview Lines" in Appearance settings for the full-width mailbox list view, allowing runtime control over the body-preview clamp. It defaults to 2 lines, and 0 disables previews
|
||||
- feat: |Frontend| Add an "Automatically load external images in mail body" toggle in Appearance settings. When disabled, the mail body (including fullscreen view) is run through DOMPurify and an allowlist policy: only references that can be *proven* local are kept (`cid:`, `data:image/`, `blob:` and same-origin relative paths), everything else is blocked. Elements that fetch on their own or change how relative URLs resolve — `base`, `meta`, `script`, `link`, `iframe`, `object`, `embed`, `noscript` — are removed in this mode, while `<style>` is kept with remote `url()`, `image-set()` and `@import` references substituted. A banner above the body reports how many resources were blocked and loads them for that mail on demand; defaults to on, preserving the previous behavior (issue #1073)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| Preserve external navigation links on `<a>` and `<area>` elements when automatic remote-image loading is disabled, and block remote CSS resources hidden behind escaped function or at-rule names
|
||||
- fix: |Frontend| Sanitize HTML announcements in both the About page and startup notification through a shared DOMPurify helper, preventing executable tags or event attributes in `ANNOUNCEMENT` from causing XSS
|
||||
- fix: |Worker| Align junk-mail checking with authentication standards: treat SPF, DKIM, and DMARC `none` plus SPF/DKIM `neutral` as absent, and ignore unregistered results and unsupported method versions; `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
|
||||
- fix: |Admin| When deleting an address from the admin panel, delete its mails, sender records, sendbox and auto-reply entries before removing the address row itself; previously the address row was deleted first, so the name-based subqueries matched nothing and the mails were left orphaned in the database
|
||||
- fix: |AI Extract| Strengthen the prompt to keep original link domains from the email, preventing small models from rewriting verification-link domains (issue #1072)
|
||||
- fix: |AI Extract| Convert HTML-only mail bodies into compact readable text before sending them to Workers AI, preventing long templates from pushing verification codes past the 4000-character truncation window
|
||||
- fix: |Frontend| Add mobile Header page padding so the title and menu button no longer sit too close to the screen edge
|
||||
- fix: |IMAP Proxy| Fix IMAP `STORE` not actually marking mail as read: messages are no longer hardcoded to `\Seen`, and `SimpleMailbox` flag changes are now persisted to a local SQLite file (new `imap_flag_db_path` setting) so the read/unread state survives a client disconnect and reconnect (e.g. Thunderbird polling) instead of resetting on every new connection (issue #1074)
|
||||
- fix: |IMAP Proxy| Fix `SEARCH UNSEEN` returning every message: `SimpleMailbox.search()` now evaluates `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` and their negations against the persisted flags, combining multiple keys with AND; search keys it cannot evaluate keep the previous behaviour of matching everything
|
||||
- fix: |IMAP Proxy| Fix fetches never marking mail as read: `BODY[...]`, `RFC822` and `RFC822.TEXT` fetches now set `\Seen` per RFC 3501, while `BODY.PEEK[...]`, `RFC822.HEADER` and metadata-only fetches (e.g. `FLAGS`) do not
|
||||
|
||||
### Testing
|
||||
|
||||
- test: |Worker| Add junk_mail_policy regression tests (issue #1084): `none`/`neutral` results for SPF/DKIM/DMARC are treated as the method being absent, explicit `fail` results are still rejected, and `JUNK_MAIL_FORCE_PASS_LIST` only accepts an explicit `pass`
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |README| Add a complete Japanese README and Japanese navigation links to the Chinese and English READMEs
|
||||
- feat: |Frontend| Lower the "Left list width in two-column mailbox view" minimum from 0.25 to 0 so the left list pane can fully collapse for a near-fullscreen content view, with a 0 mark added; applies to both the inbox and send-box two-pane splits, and clarifies the Appearance setting label so it is clear the value controls the left mail list width
|
||||
|
||||
## v1.9.0
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |AI Extract| Fall back to a built-in regex verification-code extractor (English / Chinese / Japanese / Korean, with year and `YYYYMMDD` date rejection) when no Workers AI binding is configured, so self-hosted deployments without Workers AI still surface codes in Telegram pushes and webhooks
|
||||
- feat: |Telegram| Show AI extraction results in Telegram new-mail notifications and `/mails` history views, including verification codes, auth links, service links, and subscription links
|
||||
- feat: |Webhook| Support AI extraction placeholders in mail webhook templates, including `aiExtractType`, `aiExtractResult`, and `aiExtractResultText`
|
||||
- feat: |Frontend| Add `DISABLE_SHOW_GITHUB_FOR_USER` to hide the Header GitHub/version entry from normal users while keeping it visible to admin users (issue #1041)
|
||||
- feat: |Frontend| Upgrade the address credential dialog to "Address Credentials & Connection Methods" and reuse it for both normal users and admin-created addresses; support showing AI Agent access via `ENABLE_AGENT_EMAIL_INFO` and SMTP/IMAP client settings via `SMTP_IMAP_PROXY_CONFIG`
|
||||
- docs: |Random Subdomain| Clarify in the "Use Random Subdomain" frontend tip and the `subdomain` / `worker-vars` docs (zh & en) that receiving mail on `name@<random>.abc.com` requires a wildcard `*` MX record under the base domain in DNS, because Cloudflare Email Routing does not inherit the apex configuration onto subdomains (issue #1035)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Admin| Hash address passwords in the frontend before admin reset requests, and make the backend accept and store only the hash instead of plaintext
|
||||
- fix: |Address| Stop returning stored address password hashes from the admin address list and user bound-address list APIs to avoid exposing sensitive fields
|
||||
- fix: |Address| Normalize whitespace and casing for configured domains, inbound recipient domains, and prefixes across `DOMAINS`, `DEFAULT_DOMAINS`, `USER_ROLES.domains`, random subdomains, forwarding rules, SMTP, and `SEND_MAIL` domain matching, preserve blank-domain catch-all forwarding rules, and clarify that empty `DEFAULT_DOMAINS` / role domains fall back to `DOMAINS`, to avoid create, receive, forward, or send failures caused by mixed-case configuration or inbound recipient domains (issue #926)
|
||||
- fix: |AI Extract| Switch the default Workers AI model for AI email recognition to the JSON Mode-compatible, non-deprecated `@cf/meta/llama-3.1-8b-instruct-fast`, and document structured-output compatibility guidance for `@cf/zai-org/glm-4.7-flash` (issue #1029)
|
||||
- fix: |CI| Upgrade GitHub Actions and e2e Docker images to Node.js 24 to satisfy Wrangler 4.90.0 runtime requirements
|
||||
- fix: |Frontend| Prevent iOS Safari from auto-zooming the page when focusing mobile form controls with small font sizes
|
||||
|
||||
### Improvements
|
||||
|
||||
## v1.8.0
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
@@ -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)
|
||||
+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/*
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-slim
|
||||
FROM node:24-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-slim
|
||||
FROM node:24-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { APIRequestContext } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import { createHash } from 'crypto';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
export const WORKER_URL = process.env.WORKER_URL!;
|
||||
@@ -10,6 +11,13 @@ export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||
export const TEST_DOMAIN = 'test.example.com';
|
||||
|
||||
/**
|
||||
* SHA-256 hash matching the frontend hashPassword utility.
|
||||
*/
|
||||
export function hashPassword(password: string): string {
|
||||
return createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new email address via the worker API.
|
||||
* Appends a timestamp suffix to avoid UNIQUE constraint collisions
|
||||
|
||||
@@ -5,9 +5,13 @@ compatibility_flags = [ "nodejs_compat" ]
|
||||
keep_vars = true
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
PREFIX = "TMP"
|
||||
DEFAULT_DOMAINS = []
|
||||
DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
USER_ROLES = [
|
||||
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
|
||||
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||
]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
@@ -19,8 +23,9 @@ 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}}
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
|
||||
@@ -9,10 +9,10 @@ send_email = [
|
||||
]
|
||||
|
||||
[vars]
|
||||
PREFIX = "tmp"
|
||||
DEFAULT_DOMAINS = ["test.example.com"]
|
||||
DOMAINS = ["test.example.com"]
|
||||
SEND_MAIL_DOMAINS = ["test.example.com"]
|
||||
PREFIX = "TMP"
|
||||
DEFAULT_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
SEND_MAIL_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
@@ -25,7 +25,7 @@ ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
SMTP_CONFIG = """
|
||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
"""
|
||||
|
||||
[[kv_namespaces]]
|
||||
|
||||
Generated
+74
-93
@@ -6,14 +6,14 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
"imapflow": "^1.6.5",
|
||||
"nodemailer": "^9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -23,35 +23,35 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "7.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -69,13 +69,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"version": "5.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
|
||||
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
|
||||
"version": "5.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
|
||||
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libmime": "5.4.1",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -113,9 +113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -129,38 +129,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
|
||||
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.6.5.tgz",
|
||||
"integrity": "sha512-5XgLcfl6blDju2SP7TgTv4WIlTt7zxXYcfjznJFmfFrogHR2XzHGlS9s2wdqxv9ZJ5CPaRnig7KaK7qNoJPmlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.8",
|
||||
"@zone-eu/mailsplit": "5.4.14",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.8",
|
||||
"libmime": "5.4.1",
|
||||
"libqp": "2.1.1",
|
||||
"nodemailer": "8.0.5",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.7"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow/node_modules/libmime": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.7.2",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
"socks": "2.8.9"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
|
||||
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -173,29 +160,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
|
||||
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
@@ -203,9 +178,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
|
||||
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz",
|
||||
"integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -258,41 +233,41 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -346,12 +321,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"version": "2.8.9",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -378,28 +353,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
|
||||
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+6
-6
@@ -7,13 +7,13 @@
|
||||
"test:down": "docker compose down -v"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/ws": "^8.5.0",
|
||||
"ws": "^8.18.0"
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.3.1",
|
||||
"nodemailer": "^8.0.5"
|
||||
"imapflow": "^1.6.5",
|
||||
"nodemailer": "^9.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,16 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Address Password Login', () => {
|
||||
test('set password then login with it', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-login');
|
||||
const passwordHash = hashPassword('test-password-123');
|
||||
|
||||
try {
|
||||
// Set a password on the address
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'test-password-123' },
|
||||
data: { new_password: passwordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
const changePwdBody = await changePwdRes.json();
|
||||
@@ -17,7 +18,7 @@ test.describe('Address Password Login', () => {
|
||||
|
||||
// Login with the correct password
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: 'test-password-123' },
|
||||
data: { email: address, password: passwordHash },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const loginBody = await loginRes.json();
|
||||
@@ -36,12 +37,13 @@ test.describe('Address Password Login', () => {
|
||||
|
||||
test('login with wrong password returns 401', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
|
||||
const passwordHash = hashPassword('correct-password');
|
||||
|
||||
try {
|
||||
// Set a password
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'correct-password' },
|
||||
data: { new_password: passwordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
const changePwdBody = await changePwdRes.json();
|
||||
@@ -49,11 +51,117 @@ test.describe('Address Password Login', () => {
|
||||
|
||||
// Login with wrong password
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: 'wrong-password' },
|
||||
data: { email: address, password: hashPassword('wrong-password') },
|
||||
});
|
||||
expect(loginRes.status()).toBe(401);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin reset stores frontend-hashed address password', async ({ request }) => {
|
||||
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-admin-reset');
|
||||
const plainPassword = `admin-reset-${Date.now()}`;
|
||||
const passwordHash = hashPassword(plainPassword);
|
||||
|
||||
try {
|
||||
const resetRes = await request.post(`${WORKER_URL}/admin/address/${address_id}/reset_password`, {
|
||||
data: { password: passwordHash },
|
||||
});
|
||||
expect(resetRes.ok()).toBe(true);
|
||||
await expect(resetRes.json()).resolves.toMatchObject({ success: true });
|
||||
|
||||
const plaintextLoginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: plainPassword },
|
||||
});
|
||||
expect(plaintextLoginRes.status()).toBe(401);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: { email: address, password: passwordHash },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const loginBody = await loginRes.json();
|
||||
expect(loginBody.jwt).toBeTruthy();
|
||||
expect(loginBody.address).toBe(address);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('admin address list does not expose stored password hash', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-list-hidden');
|
||||
const passwordHash = hashPassword('list-hidden-password');
|
||||
|
||||
try {
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: passwordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
|
||||
const listRes = await request.get(
|
||||
`${WORKER_URL}/admin/address?limit=10&offset=0&query=${encodeURIComponent(address)}`
|
||||
);
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const listBody = await listRes.json();
|
||||
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
|
||||
expect(listedAddress).toBeTruthy();
|
||||
expect(listedAddress).not.toHaveProperty('password');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('user bind address list does not expose stored password hash', async ({ request }) => {
|
||||
const userEmail = `pwd-bind-hidden-${Date.now()}@test.example.com`;
|
||||
const userPasswordHash = hashPassword('bind-hidden-user-password');
|
||||
const { jwt, address } = await createTestAddress(request, 'pwd-bind-hidden');
|
||||
const addressPasswordHash = hashPassword('bind-hidden-address-password');
|
||||
|
||||
try {
|
||||
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
},
|
||||
});
|
||||
expect(enableRes.ok()).toBe(true);
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email: userEmail, password: userPasswordHash },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email: userEmail, password: userPasswordHash },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
|
||||
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: addressPasswordHash },
|
||||
});
|
||||
expect(changePwdRes.ok()).toBe(true);
|
||||
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
|
||||
const listRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(listRes.ok()).toBe(true);
|
||||
const listBody = await listRes.json();
|
||||
const listedAddress = listBody.results.find((row: { name: string }) => row.name === address);
|
||||
expect(listedAddress).toBeTruthy();
|
||||
expect(listedAddress).not.toHaveProperty('password');
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,4 +16,116 @@ test.describe('Admin New Address', () => {
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
expect(typeof body.address_id).toBe('number');
|
||||
});
|
||||
|
||||
test('normalizes uppercase configured prefix and domain', async ({ request }) => {
|
||||
const uniqueName = `admincase${Date.now()}`;
|
||||
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: TEST_DOMAIN.toUpperCase(), enablePrefix: true },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('falls back to domains when default domains is empty', async ({ request }) => {
|
||||
const uniqueName = `fallback${Date.now().toString(36)}`;
|
||||
const res = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
data: { name: uniqueName },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.address).toBe(`tmp${uniqueName}@${TEST_DOMAIN}`);
|
||||
expect(body.jwt).toBeTruthy();
|
||||
expect(body.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('normalizes user role domains and prefix', async ({ request }) => {
|
||||
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `role-case-${suffix}@${TEST_DOMAIN}`;
|
||||
const password = `role-pass-${suffix}`;
|
||||
const name = `rolecase${suffix}`;
|
||||
|
||||
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(createUserRes.ok()).toBe(true);
|
||||
|
||||
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: '20', offset: '0', query: email },
|
||||
});
|
||||
expect(usersRes.ok()).toBe(true);
|
||||
const usersBody = await usersRes.json();
|
||||
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
|
||||
expect(user).toBeTruthy();
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.id, role_text: 'case-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
|
||||
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { name },
|
||||
});
|
||||
expect(createAddressRes.ok()).toBe(true);
|
||||
const addressBody = await createAddressRes.json();
|
||||
|
||||
expect(addressBody.address).toBe(`role${name}@${TEST_DOMAIN}`);
|
||||
expect(addressBody.jwt).toBeTruthy();
|
||||
expect(addressBody.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('falls back to default domains when user role domains is empty', async ({ request }) => {
|
||||
const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `empty-role-${suffix}@${TEST_DOMAIN}`;
|
||||
const password = `empty-role-pass-${suffix}`;
|
||||
const name = `emptyrole${suffix}`;
|
||||
|
||||
const createUserRes = await request.post(`${WORKER_URL}/admin/users`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(createUserRes.ok()).toBe(true);
|
||||
|
||||
const usersRes = await request.get(`${WORKER_URL}/admin/users`, {
|
||||
params: { limit: '20', offset: '0', query: email },
|
||||
});
|
||||
expect(usersRes.ok()).toBe(true);
|
||||
const usersBody = await usersRes.json();
|
||||
const user = usersBody.results.find((row: { user_email: string }) => row.user_email === email);
|
||||
expect(user).toBeTruthy();
|
||||
|
||||
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||
data: { user_id: user.id, role_text: 'empty-role' },
|
||||
});
|
||||
expect(updateRoleRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt: userJwt } = await loginRes.json();
|
||||
|
||||
const createAddressRes = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
data: { name },
|
||||
});
|
||||
expect(createAddressRes.ok()).toBe(true);
|
||||
const addressBody = await createAddressRes.json();
|
||||
|
||||
expect(addressBody.address).toBe(`empty${name}@${TEST_DOMAIN}`);
|
||||
expect(addressBody.jwt).toBeTruthy();
|
||||
expect(addressBody.address_id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
TEST_DOMAIN,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_PASSWORD = 'e2e-admin-pass';
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': ADMIN_PASSWORD };
|
||||
|
||||
const DEFAULT_ACCOUNT_SETTINGS = {
|
||||
blockList: [],
|
||||
sendBlockList: [],
|
||||
verifiedAddressList: [],
|
||||
fromBlockList: [],
|
||||
noLimitSendAddressList: [],
|
||||
emailRuleSettings: {},
|
||||
addressCreationSettings: {},
|
||||
};
|
||||
|
||||
async function resetAccountSettings(request: APIRequestContext) {
|
||||
const res = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: DEFAULT_ACCOUNT_SETTINGS,
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Email forward domain normalization', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await resetAccountSettings(request);
|
||||
});
|
||||
|
||||
test('normalizes uppercase forwarding rule and recipient domains', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'forward-case');
|
||||
const forwardAddress = 'forward-target@test.example.com';
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
...DEFAULT_ACCOUNT_SETTINGS,
|
||||
emailRuleSettings: {
|
||||
emailForwardingList: [{
|
||||
domains: [TEST_DOMAIN.toUpperCase()],
|
||||
forward: forwardAddress,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const to = address.replace(`@${TEST_DOMAIN}`, `@${TEST_DOMAIN.toUpperCase()}`);
|
||||
const subject = `forward-case-${Date.now()}`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <${subject}@test>`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Forward domain normalization test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.forwardedTo).toEqual([forwardAddress]);
|
||||
|
||||
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const mailsBody = await mailsRes.json();
|
||||
expect(mailsBody.results.some((mail: { address: string; raw: string }) => {
|
||||
return mail.address === address && mail.raw.includes(subject);
|
||||
})).toBe(true);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not forward when only the domain suffix string matches', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'forward-boundary');
|
||||
const forwardAddress = 'forward-boundary-target@test.example.com';
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
...DEFAULT_ACCOUNT_SETTINGS,
|
||||
emailRuleSettings: {
|
||||
emailForwardingList: [{
|
||||
domains: [TEST_DOMAIN],
|
||||
forward: forwardAddress,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const to = address.replace(`@${TEST_DOMAIN}`, `@evil${TEST_DOMAIN}`);
|
||||
const subject = `forward-boundary-${Date.now()}`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <${subject}@test>`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Forward domain boundary test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.forwardedTo).toEqual([]);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps blank forwarding rule domain as catch-all', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'forward-catch-all');
|
||||
const forwardAddress = 'forward-catch-all-target@test.example.com';
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/account_settings`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
...DEFAULT_ACCOUNT_SETTINGS,
|
||||
emailRuleSettings: {
|
||||
emailForwardingList: [{
|
||||
domains: ['', 'not-the-domain.example.com'],
|
||||
forward: forwardAddress,
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const subject = `forward-catch-all-${Date.now()}`;
|
||||
const raw = [
|
||||
`From: sender@test.example.com`,
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <${subject}@test>`,
|
||||
`MIME-Version: 1.0`,
|
||||
`Content-Type: text/plain; charset=utf-8`,
|
||||
``,
|
||||
`Forward catch-all domain test`,
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from: 'sender@test.example.com', to: address, raw },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.forwardedTo).toEqual([forwardAddress]);
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ test.describe('Health & Settings', () => {
|
||||
const settings = await res.json();
|
||||
expect(settings.domains).toContain('test.example.com');
|
||||
expect(settings.defaultDomains).toContain('test.example.com');
|
||||
expect(settings.prefix).toBe('tmp');
|
||||
expect(settings.enableSendMail).toBe(true);
|
||||
expect(settings.enableUserCreateEmail).toBe(true);
|
||||
expect(settings.enableUserDeleteEmail).toBe(true);
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* SHA-256 hash matching frontend hashPassword utility.
|
||||
*/
|
||||
function hashPassword(password: string): string {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
|
||||
|
||||
@@ -110,14 +102,14 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
||||
// Set a password
|
||||
await request.post(`${WORKER_URL}/api/address_change_password`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
data: { new_password: 'addr-pass-123' },
|
||||
data: { new_password: hashPassword('addr-pass-123') },
|
||||
});
|
||||
|
||||
// Login with cf_token field present but empty
|
||||
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||
data: {
|
||||
email: address,
|
||||
password: 'addr-pass-123',
|
||||
password: hashPassword('addr-pass-123'),
|
||||
cf_token: ''
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect, APIRequestContext } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
WORKER_URL_SEND_MAIL_DOMAIN,
|
||||
@@ -425,11 +426,11 @@ test.describe('Send Mail Limit', () => {
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding returns 200 when domain is allowed', async ({ request }) => {
|
||||
test('/admin/send_mail_by_binding normalizes uppercase allowed domain', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: 'admin@test.example.com',
|
||||
from: 'admin@TEST.EXAMPLE.COM',
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: `send-mail-domain-ok-${Date.now()}`,
|
||||
text: 'body',
|
||||
@@ -439,6 +440,20 @@ test.describe('Send Mail Limit', () => {
|
||||
expect(await res.json()).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding normalizes object-form from domain', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: {
|
||||
from: { email: 'admin@TEST.EXAMPLE.COM', name: 'Admin' },
|
||||
to: ['recipient@test.example.com'],
|
||||
subject: `send-mail-domain-object-ok-${Date.now()}`,
|
||||
text: 'body',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
expect(await res.json()).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
test('/admin/send_mail_by_binding returns 400 when domain is not allowed', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL_SEND_MAIL_DOMAIN}/admin/send_mail_by_binding`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test.describe('Telegram AI extraction rendering', () => {
|
||||
test('realtime mail stores AI extraction metadata for Telegram rendering', async ({ request }) => {
|
||||
const { jwt, address } = await createTestAddress(request, 'tg-ai');
|
||||
|
||||
try {
|
||||
const subject = `Telegram AI realtime ${Date.now()}`;
|
||||
const raw = [
|
||||
'From: sender@test.example.com',
|
||||
`To: ${address}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: <telegram-ai-${Date.now()}@test>`,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Telegram AI extraction realtime body',
|
||||
].join('\r\n');
|
||||
|
||||
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: {
|
||||
from: 'sender@test.example.com',
|
||||
to: address,
|
||||
raw,
|
||||
ai_extract_result: {
|
||||
type: 'auth_code',
|
||||
result: '123456',
|
||||
result_text: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(receiveRes.ok()).toBe(true);
|
||||
const receiveBody = await receiveRes.json();
|
||||
expect(receiveBody.success).toBe(true);
|
||||
|
||||
const mailsRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(mailsRes.ok()).toBe(true);
|
||||
const { results } = await mailsRes.json();
|
||||
expect(results).toHaveLength(1);
|
||||
|
||||
const metadata = JSON.parse(results[0].metadata);
|
||||
expect(metadata.ai_extract).toEqual({
|
||||
type: 'auth_code',
|
||||
result: '123456',
|
||||
result_text: '',
|
||||
});
|
||||
expect(metadata.extracted_at).toBeTruthy();
|
||||
} finally {
|
||||
await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||
import {
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
seedTestMail,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `address-page-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
|
||||
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginRes.ok()).toBe(true);
|
||||
const { jwt } = await loginRes.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
test.describe('User address pagination', () => {
|
||||
test('paginates addresses and enforces mail ownership', async ({ request }) => {
|
||||
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let outsider: Awaited<ReturnType<typeof createTestAddress>> | undefined;
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsRes.ok()).toBe(true);
|
||||
originalUserSettings = await settingsRes.json();
|
||||
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
},
|
||||
});
|
||||
expect(enableUserRes.ok()).toBe(true);
|
||||
|
||||
const user = await createUser(request);
|
||||
const userJwt = user.jwt;
|
||||
userId = user.userId;
|
||||
addresses.push(...await Promise.all([
|
||||
createTestAddress(request, 'user-page-a'),
|
||||
createTestAddress(request, 'user-page-b'),
|
||||
createTestAddress(request, 'user-page-c'),
|
||||
]));
|
||||
outsider = await createTestAddress(request, 'user-page-outsider');
|
||||
|
||||
for (const item of addresses) {
|
||||
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${item.jwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(bindRes.ok()).toBe(true);
|
||||
}
|
||||
|
||||
const defaultPageRes = await request.get(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(defaultPageRes.ok()).toBe(true);
|
||||
const defaultPage = await defaultPageRes.json();
|
||||
expect(defaultPage.count).toBe(3);
|
||||
expect(defaultPage.results).toHaveLength(3);
|
||||
|
||||
const firstPageRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=2&offset=0`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(firstPageRes.ok()).toBe(true);
|
||||
const firstPage = await firstPageRes.json();
|
||||
expect(firstPage.count).toBe(3);
|
||||
expect(firstPage.results).toHaveLength(2);
|
||||
expect(firstPage.results[0].mail_count).toBe(0);
|
||||
expect(firstPage.results[0].send_count).toBe(0);
|
||||
expect(firstPage.results[0]).toHaveProperty('source_meta');
|
||||
expect(firstPage.results[0]).not.toHaveProperty('password');
|
||||
|
||||
const secondPageRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=2&offset=2`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(secondPageRes.ok()).toBe(true);
|
||||
const secondPage = await secondPageRes.json();
|
||||
expect(secondPage.count).toBe(0);
|
||||
expect(secondPage.results).toHaveLength(1);
|
||||
|
||||
const invalidLimitRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=101&offset=0`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(invalidLimitRes.status()).toBe(400);
|
||||
|
||||
const invalidOffsetRes = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address?limit=20&offset=-1`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(invalidOffsetRes.status()).toBe(400);
|
||||
|
||||
await seedTestMail(request, addresses[0].address, { subject: 'Bound mail' });
|
||||
await seedTestMail(request, outsider.address, { subject: 'Outsider mail' });
|
||||
|
||||
const userMailsRes = await request.get(`${WORKER_URL}/user_api/mails?limit=20&offset=0`, {
|
||||
headers: { 'x-user-token': userJwt },
|
||||
});
|
||||
expect(userMailsRes.ok()).toBe(true);
|
||||
const userMails = await userMailsRes.json();
|
||||
expect(userMails.count).toBe(1);
|
||||
expect(userMails.results[0].address).toBe(addresses[0].address);
|
||||
|
||||
const filteredOutsiderMailsRes = await request.get(
|
||||
`${WORKER_URL}/user_api/mails?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(filteredOutsiderMailsRes.ok()).toBe(true);
|
||||
const filteredOutsiderMails = await filteredOutsiderMailsRes.json();
|
||||
expect(filteredOutsiderMails.count).toBe(0);
|
||||
expect(filteredOutsiderMails.results).toHaveLength(0);
|
||||
|
||||
const outsiderMailsRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
});
|
||||
expect(outsiderMailsRes.ok()).toBe(true);
|
||||
const outsiderMails = await outsiderMailsRes.json();
|
||||
expect(outsiderMails.results).toHaveLength(1);
|
||||
|
||||
const forbiddenDeleteRes = await request.delete(
|
||||
`${WORKER_URL}/user_api/mails/${outsiderMails.results[0].id}`,
|
||||
{ headers: { 'x-user-token': userJwt } },
|
||||
);
|
||||
expect(forbiddenDeleteRes.ok()).toBe(true);
|
||||
|
||||
const outsiderAfterRes = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||
});
|
||||
expect(outsiderAfterRes.ok()).toBe(true);
|
||||
const outsiderAfter = await outsiderAfterRes.json();
|
||||
expect(outsiderAfter.results).toHaveLength(1);
|
||||
} finally {
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
[...addresses, outsider].filter((item) => item !== undefined)
|
||||
.map((item) => deleteAddress(request, item.jwt)),
|
||||
);
|
||||
if (userId !== undefined) {
|
||||
const deleteUserRes = await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
expect(deleteUserRes.ok()).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
const restoreSettingsRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: originalUserSettings,
|
||||
});
|
||||
expect(restoreSettingsRes.ok()).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
import http from 'node:http';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
async function resetUserSettings(request: APIRequestContext) {
|
||||
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
enableMailAllowList: false,
|
||||
mailAllowList: [],
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function resetOauth2Settings(request: APIRequestContext) {
|
||||
const res = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
|
||||
data: [],
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function startOauthServer(email: string): Promise<{ server: http.Server; baseUrl: string }> {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/token') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ access_token: 'token', token_type: 'Bearer' }));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/userinfo') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ email }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('not found');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '0.0.0.0', resolve));
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('Failed to resolve OAuth test server port');
|
||||
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
|
||||
return { server, baseUrl: `http://${hostname}:${addr.port}` };
|
||||
}
|
||||
|
||||
test.describe('User domain normalization', () => {
|
||||
test.afterEach(async ({ request }) => {
|
||||
await resetOauth2Settings(request);
|
||||
await resetUserSettings(request);
|
||||
});
|
||||
|
||||
test('normalizes uppercase verify mail sender domain in admin settings', async ({ request }) => {
|
||||
const res = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: true,
|
||||
verifyMailSender: 'verify@TEST.EXAMPLE.COM',
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes uppercase user registration allow-list domains', async ({ request }) => {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||
data: {
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
enableMailAllowList: true,
|
||||
mailAllowList: ['TEST.EXAMPLE.COM'],
|
||||
},
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: {
|
||||
email: `allow-list-${Date.now()}@TEST.EXAMPLE.COM`,
|
||||
password: 'allow-list-password',
|
||||
},
|
||||
});
|
||||
expect(registerRes.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes uppercase OAuth2 allow-list domains', async ({ request }) => {
|
||||
const email = `oauth-allow-${Date.now()}@TEST.EXAMPLE.COM`;
|
||||
const { server, baseUrl } = await startOauthServer(email);
|
||||
|
||||
try {
|
||||
const saveRes = await request.post(`${WORKER_URL}/admin/user_oauth2_settings`, {
|
||||
data: [{
|
||||
name: 'case-oauth',
|
||||
clientID: 'case-client',
|
||||
clientSecret: 'case-secret',
|
||||
authorizationURL: `${baseUrl}/authorize`,
|
||||
accessTokenURL: `${baseUrl}/token`,
|
||||
accessTokenFormat: 'json',
|
||||
userInfoURL: `${baseUrl}/userinfo`,
|
||||
redirectURL: `${baseUrl}/callback`,
|
||||
userEmailKey: 'email',
|
||||
scope: 'openid email',
|
||||
enableMailAllowList: true,
|
||||
mailAllowList: ['TEST.EXAMPLE.COM'],
|
||||
}],
|
||||
});
|
||||
expect(saveRes.ok()).toBe(true);
|
||||
|
||||
const callbackRes = await request.post(`${WORKER_URL}/user_api/oauth2/callback`, {
|
||||
data: { clientID: 'case-client', code: 'case-code' },
|
||||
});
|
||||
expect(callbackRes.ok()).toBe(true);
|
||||
const body = await callbackRes.json();
|
||||
expect(body.jwt).toBeTruthy();
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,9 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
||||
from: '${from}',
|
||||
to: '${to}',
|
||||
subject: '${subject}',
|
||||
aiExtractType: '${aiExtractType}',
|
||||
aiExtractResult: '${aiExtractResult}',
|
||||
aiExtractResultText: '${aiExtractResultText}',
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -93,7 +96,16 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
||||
].join('\r\n');
|
||||
|
||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
||||
data: { from, to: address, raw },
|
||||
data: {
|
||||
from,
|
||||
to: address,
|
||||
raw,
|
||||
ai_extract_result: {
|
||||
type: 'auth_code',
|
||||
result: '654321',
|
||||
result_text: 'Login verification code',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
@@ -106,6 +118,9 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
||||
expect(payload.from).toContain('webhook-sender@test.example.com');
|
||||
expect(payload.to).toBe(address);
|
||||
expect(payload.subject).toBe(subject);
|
||||
expect(payload.aiExtractType).toBe('auth_code');
|
||||
expect(payload.aiExtractResult).toBe('654321');
|
||||
expect(payload.aiExtractResultText).toBe('Login verification code');
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ test.describe('Passkey Browser Flow', () => {
|
||||
// Wait for user settings to load (shows user email)
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// === Step 2: Click "User Settings" tab ===
|
||||
await page.getByText('User Settings').click();
|
||||
// === Step 2: Click "User Account Settings" tab ===
|
||||
await page.getByText('User Account Settings').click();
|
||||
|
||||
// === Step 3: Create a passkey ===
|
||||
await page.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||
import type { APIRequestContext } from '@playwright/test';
|
||||
|
||||
import {
|
||||
FRONTEND_URL,
|
||||
WORKER_URL,
|
||||
createTestAddress,
|
||||
deleteAddress,
|
||||
hashPassword,
|
||||
} from '../../fixtures/test-helpers';
|
||||
|
||||
async function saveUserSettings(request: APIRequestContext, settings: Record<string, unknown>) {
|
||||
const response = await request.post(`${WORKER_URL}/admin/user_settings`, { data: settings });
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
async function createUser(request: APIRequestContext) {
|
||||
const email = `address-browser-${Date.now()}@test.example.com`;
|
||||
const password = hashPassword('test-password-123');
|
||||
const registerResponse = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(registerResponse.ok()).toBe(true);
|
||||
|
||||
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(loginResponse.ok()).toBe(true);
|
||||
const { jwt } = await loginResponse.json();
|
||||
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||
return { email, jwt, userId: payload.user_id as number };
|
||||
}
|
||||
|
||||
async function bindAddress(request: APIRequestContext, userJwt: string, addressJwt: string) {
|
||||
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${addressJwt}`,
|
||||
'x-user-token': userJwt,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('User address pagination browser flow', () => {
|
||||
test('paginates addresses and filters mail', async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const request = await apiRequest.newContext();
|
||||
const createdAddresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||
let originalUserSettings: Record<string, unknown> | undefined;
|
||||
let userId: number | undefined;
|
||||
|
||||
try {
|
||||
const settingsResponse = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||
expect(settingsResponse.ok()).toBe(true);
|
||||
originalUserSettings = await settingsResponse.json();
|
||||
await saveUserSettings(request, {
|
||||
...originalUserSettings,
|
||||
enable: true,
|
||||
enableMailVerify: false,
|
||||
maxAddressCount: 0,
|
||||
});
|
||||
|
||||
const user = await createUser(request);
|
||||
userId = user.userId;
|
||||
|
||||
for (let index = 0; index < 21; index += 1) {
|
||||
const address = await createTestAddress(request, `browser-page-${index}-`);
|
||||
createdAddresses.push(address);
|
||||
await bindAddress(request, user.jwt, address.jwt);
|
||||
}
|
||||
const defaultAddressPageResponse = await request.get(
|
||||
`${WORKER_URL}/user_api/bind_address`,
|
||||
{ headers: { 'x-user-token': user.jwt } },
|
||||
);
|
||||
expect(defaultAddressPageResponse.ok()).toBe(true);
|
||||
const defaultAddressPage = await defaultAddressPageResponse.json();
|
||||
expect(defaultAddressPage.count).toBe(21);
|
||||
expect(defaultAddressPage.results).toHaveLength(20);
|
||||
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.evaluate((userJwt) => {
|
||||
localStorage.setItem('userJwt', userJwt);
|
||||
}, user.jwt);
|
||||
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||
|
||||
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||
const pagination = page.locator('.n-pagination').first();
|
||||
const addressRows = page.locator('.n-data-table-tbody .n-data-table-tr');
|
||||
await expect(pagination).toContainText(/Total:\s*21/);
|
||||
await expect(addressRows).toHaveCount(20);
|
||||
|
||||
await pagination.locator('.n-pagination-item').filter({ hasText: /^2$/ }).click();
|
||||
await expect(addressRows).toHaveCount(1);
|
||||
|
||||
const selectedAddress = createdAddresses[20];
|
||||
|
||||
const initialMailboxAddressesResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/user_api/bind_address'
|
||||
&& url.searchParams.get('limit') === '100';
|
||||
});
|
||||
await page.getByText('Mail Box', { exact: true }).click();
|
||||
const initialMailboxResponse = await initialMailboxAddressesResponse;
|
||||
expect(initialMailboxResponse.ok()).toBe(true);
|
||||
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
|
||||
await mailboxAddressSelect.click();
|
||||
|
||||
const mailboxOptions = page.locator('.n-base-select-menu:visible');
|
||||
await expect(mailboxOptions).toContainText(selectedAddress.address);
|
||||
const filteredMailResponse = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return url.pathname === '/user_api/mails'
|
||||
&& url.searchParams.get('address') === selectedAddress.address;
|
||||
});
|
||||
await mailboxOptions.getByText(selectedAddress.address, { exact: true }).click();
|
||||
expect((await filteredMailResponse).ok()).toBe(true);
|
||||
} finally {
|
||||
try {
|
||||
try {
|
||||
await Promise.allSettled(createdAddresses.map((address) => deleteAddress(request, address.jwt)));
|
||||
if (userId !== undefined) {
|
||||
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||
}
|
||||
} finally {
|
||||
if (originalUserSettings) {
|
||||
await saveUserSettings(request, originalUserSettings);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await request.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -132,8 +132,10 @@ test.describe('IMAP Proxy', () => {
|
||||
try {
|
||||
const results = await client.search({ all: true });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
const msg = await client.fetchOne(String(results[0]), { uid: true, flags: true }, { uid: true });
|
||||
expect(msg.uid).toBe(results[0]);
|
||||
const seqMsg = await client.fetchOne(String(results[0]), { uid: true, flags: true });
|
||||
expect(seqMsg.uid).toBeGreaterThan(0);
|
||||
const uidMsg = await client.fetchOne(String(seqMsg.uid), { uid: true, flags: true }, { uid: true });
|
||||
expect(uidMsg.uid).toBe(seqMsg.uid);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Temp Email Next</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "cloudflare-temp-email-next",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.0",
|
||||
"lucide-react": "^0.561.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0"
|
||||
}
|
||||
Generated
+3210
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || ""
|
||||
|
||||
export type DomainOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type OpenSettings = {
|
||||
fetched: boolean
|
||||
title: string
|
||||
prefix: string
|
||||
addressRegex: string
|
||||
minAddressLen: number
|
||||
maxAddressLen: number
|
||||
needAuth: boolean
|
||||
enableUserCreateEmail: boolean
|
||||
disableAnonymousUserCreateEmail: boolean
|
||||
disableCustomAddressName: boolean
|
||||
enableUserDeleteEmail: boolean
|
||||
enableSendMail: boolean
|
||||
enableAddressPassword: boolean
|
||||
defaultDomains: string[]
|
||||
randomSubdomainDomains: string[]
|
||||
domains: DomainOption[]
|
||||
cfTurnstileSiteKey: string
|
||||
enableGlobalTurnstileCheck: boolean
|
||||
}
|
||||
|
||||
export type AddressSettings = {
|
||||
fetched: boolean
|
||||
address: string
|
||||
send_balance: number
|
||||
auto_reply?: unknown
|
||||
}
|
||||
|
||||
export type UserOpenSettings = {
|
||||
fetched: boolean
|
||||
enable: boolean
|
||||
enableMailVerify: boolean
|
||||
oauth2ClientIDs: { clientID: string; name: string; icon?: string }[]
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
fetched: boolean
|
||||
user_email: string
|
||||
user_id: number
|
||||
is_admin: boolean
|
||||
access_token: string | null
|
||||
new_user_token: string | null
|
||||
user_role: { domains?: string[] | null; role: string; prefix?: string | null } | null
|
||||
}
|
||||
|
||||
export type BoundAddress = {
|
||||
id: number | string
|
||||
name?: string
|
||||
address?: string
|
||||
mail_count?: number
|
||||
send_count?: number
|
||||
}
|
||||
|
||||
export type MailItem = {
|
||||
id: number | string
|
||||
source?: string
|
||||
address?: string
|
||||
subject?: string
|
||||
sender?: string
|
||||
html?: string
|
||||
message?: string
|
||||
text?: string
|
||||
raw?: string
|
||||
created_at?: string
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type MailListResponse = {
|
||||
results: MailItem[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export type SendMailPayload = {
|
||||
from_name: string
|
||||
to_name: string
|
||||
to_mail: string
|
||||
subject: string
|
||||
is_html: boolean
|
||||
content: string
|
||||
}
|
||||
|
||||
export const defaultOpenSettings: OpenSettings = {
|
||||
fetched: false,
|
||||
title: "",
|
||||
prefix: "",
|
||||
addressRegex: "",
|
||||
minAddressLen: 1,
|
||||
maxAddressLen: 30,
|
||||
needAuth: false,
|
||||
enableUserCreateEmail: false,
|
||||
disableAnonymousUserCreateEmail: false,
|
||||
disableCustomAddressName: false,
|
||||
enableUserDeleteEmail: false,
|
||||
enableSendMail: false,
|
||||
enableAddressPassword: false,
|
||||
defaultDomains: [],
|
||||
randomSubdomainDomains: [],
|
||||
domains: [],
|
||||
cfTurnstileSiteKey: "",
|
||||
enableGlobalTurnstileCheck: false,
|
||||
}
|
||||
|
||||
export const defaultAddressSettings: AddressSettings = {
|
||||
fetched: false,
|
||||
address: "",
|
||||
send_balance: 0,
|
||||
}
|
||||
|
||||
export const defaultUserOpenSettings: UserOpenSettings = {
|
||||
fetched: false,
|
||||
enable: false,
|
||||
enableMailVerify: false,
|
||||
oauth2ClientIDs: [],
|
||||
}
|
||||
|
||||
export const defaultUserSettings: UserSettings = {
|
||||
fetched: false,
|
||||
user_email: "",
|
||||
user_id: 0,
|
||||
is_admin: false,
|
||||
access_token: null,
|
||||
new_user_token: null,
|
||||
user_role: null,
|
||||
}
|
||||
|
||||
const hasControlChar = (value: string) => {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code < 32 || code === 127) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const safeHeaderValue = (value: string | null | undefined) => {
|
||||
if (!value) return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined
|
||||
if (hasControlChar(trimmed)) return undefined
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const safeBearerHeader = (jwt: string | null | undefined) => {
|
||||
const safe = safeHeaderValue(jwt)
|
||||
return safe ? `Bearer ${safe}` : undefined
|
||||
}
|
||||
|
||||
const normalizeOpenSettings = (payload: Record<string, unknown>): OpenSettings => {
|
||||
const domains = Array.isArray(payload.domains) ? (payload.domains as string[]) : []
|
||||
const domainLabels = Array.isArray(payload.domainLabels) ? (payload.domainLabels as string[]) : []
|
||||
return {
|
||||
...defaultOpenSettings,
|
||||
fetched: true,
|
||||
title: String(payload.title || ""),
|
||||
prefix: String(payload.prefix || ""),
|
||||
addressRegex: String(payload.addressRegex || ""),
|
||||
minAddressLen: Number(payload.minAddressLen ?? 1),
|
||||
maxAddressLen: Number(payload.maxAddressLen ?? 30),
|
||||
needAuth: Boolean(payload.needAuth),
|
||||
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
|
||||
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
|
||||
disableCustomAddressName: Boolean(payload.disableCustomAddressName),
|
||||
enableUserDeleteEmail: Boolean(payload.enableUserDeleteEmail),
|
||||
enableSendMail: Boolean(payload.enableSendMail),
|
||||
enableAddressPassword: Boolean(payload.enableAddressPassword),
|
||||
defaultDomains: Array.isArray(payload.defaultDomains) ? (payload.defaultDomains as string[]) : [],
|
||||
randomSubdomainDomains: Array.isArray(payload.randomSubdomainDomains)
|
||||
? (payload.randomSubdomainDomains as string[])
|
||||
: [],
|
||||
domains: domains.map((domain, index) => ({
|
||||
label: domainLabels[index] || domain,
|
||||
value: domain,
|
||||
})),
|
||||
cfTurnstileSiteKey: String(payload.cfTurnstileSiteKey || ""),
|
||||
enableGlobalTurnstileCheck: Boolean(payload.enableGlobalTurnstileCheck),
|
||||
}
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password))
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
export function parseJwtAddress(jwt: string) {
|
||||
try {
|
||||
const payload = JSON.parse(decodeURIComponent(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))))
|
||||
return typeof payload.address === "string" ? payload.address : ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(value?: string) {
|
||||
if (!value) return ""
|
||||
const date = new Date(`${value} UTC`)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
constructor(
|
||||
private readonly getJwt: () => string,
|
||||
private readonly getCustomAuth: () => string,
|
||||
private readonly getUserJwt: () => string,
|
||||
) {}
|
||||
|
||||
async request<T>(path: string, options: RequestInit = {}) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"x-lang": "en",
|
||||
}
|
||||
const customAuth = safeHeaderValue(this.getCustomAuth())
|
||||
const userJwt = safeHeaderValue(this.getUserJwt())
|
||||
const authorization = safeBearerHeader(this.getJwt())
|
||||
if (customAuth) headers["x-custom-auth"] = customAuth
|
||||
if (userJwt) headers["x-user-token"] = userJwt
|
||||
if (authorization) headers.Authorization = authorization
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
},
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = text ? tryParseJson(text) : null
|
||||
if (response.status >= 300) {
|
||||
throw new Error(typeof data === "string" ? data : text || `[${response.status}] request failed`)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
async getOpenSettings() {
|
||||
return normalizeOpenSettings(await this.request<Record<string, unknown>>("/open_api/settings"))
|
||||
}
|
||||
|
||||
async getSettings() {
|
||||
if (!safeHeaderValue(this.getJwt())) return { ...defaultAddressSettings, fetched: true }
|
||||
const payload = await this.request<Record<string, unknown>>("/api/settings")
|
||||
return {
|
||||
fetched: true,
|
||||
address: String(payload.address || ""),
|
||||
send_balance: Number(payload.send_balance || 0),
|
||||
auto_reply: payload.auto_reply,
|
||||
} satisfies AddressSettings
|
||||
}
|
||||
|
||||
async getUserOpenSettings() {
|
||||
return {
|
||||
...defaultUserOpenSettings,
|
||||
...(await this.request<Partial<UserOpenSettings>>("/user_api/open_settings")),
|
||||
fetched: true,
|
||||
} satisfies UserOpenSettings
|
||||
}
|
||||
|
||||
async getUserSettings() {
|
||||
if (!safeHeaderValue(this.getUserJwt())) return { ...defaultUserSettings, fetched: true }
|
||||
return {
|
||||
...defaultUserSettings,
|
||||
...(await this.request<Partial<UserSettings>>("/user_api/settings")),
|
||||
fetched: true,
|
||||
} satisfies UserSettings
|
||||
}
|
||||
|
||||
async userLogin(email: string, password: string, cfToken: string) {
|
||||
const response = await this.request<{ jwt: string }>("/user_api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password: await hashPassword(password),
|
||||
cf_token: cfToken,
|
||||
}),
|
||||
})
|
||||
return response.jwt
|
||||
}
|
||||
|
||||
async sendUserVerifyCode(email: string, cfToken: string) {
|
||||
return this.request<{ expirationTtl?: number }>("/user_api/verify_code", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, cf_token: cfToken }),
|
||||
})
|
||||
}
|
||||
|
||||
async userRegister(email: string, password: string, code: string, cfToken: string) {
|
||||
await this.request("/user_api/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password: await hashPassword(password),
|
||||
code,
|
||||
cf_token: cfToken,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async listBoundAddresses() {
|
||||
const response = await this.request<{ results: BoundAddress[] }>("/user_api/bind_address")
|
||||
return response.results || []
|
||||
}
|
||||
|
||||
async bindCurrentAddress() {
|
||||
await this.request("/user_api/bind_address", { method: "POST" })
|
||||
}
|
||||
|
||||
async getBoundAddressJwt(addressId: string | number) {
|
||||
const response = await this.request<{ jwt: string }>(`/user_api/bind_address_jwt/${addressId}`)
|
||||
return response.jwt
|
||||
}
|
||||
|
||||
async unbindAddress(addressId: string | number) {
|
||||
await this.request("/user_api/unbind_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ address_id: addressId }),
|
||||
})
|
||||
}
|
||||
|
||||
async createAddress(name: string, domain: string, cfToken: string, enableRandomSubdomain: boolean) {
|
||||
return this.request<{ jwt: string; password?: string }>("/api/new_address", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
domain,
|
||||
cf_token: cfToken,
|
||||
enableRandomSubdomain,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async credentialLogin(credential: string, cfToken: string) {
|
||||
await this.request("/open_api/credential_login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential, cf_token: cfToken }),
|
||||
})
|
||||
return credential
|
||||
}
|
||||
|
||||
async passwordLogin(email: string, password: string, cfToken: string) {
|
||||
const response = await this.request<{ jwt: string }>("/api/address_login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password: await hashPassword(password),
|
||||
cf_token: cfToken,
|
||||
}),
|
||||
})
|
||||
return response.jwt
|
||||
}
|
||||
|
||||
async listMails(limit: number, offset: number) {
|
||||
return this.request<MailListResponse>(`/api/parsed_mails?limit=${limit}&offset=${offset}`)
|
||||
}
|
||||
|
||||
async deleteMail(id: string | number) {
|
||||
await this.request(`/api/mails/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
async sendMail(payload: SendMailPayload) {
|
||||
await this.request("/api/send_mail", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
async requestSendAccess() {
|
||||
await this.request("/api/request_send_mail_access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
async clearInbox() {
|
||||
await this.request("/api/clear_inbox", { method: "DELETE" })
|
||||
}
|
||||
|
||||
async clearSentItems() {
|
||||
await this.request("/api/clear_sent_items", { method: "DELETE" })
|
||||
}
|
||||
|
||||
async deleteAddress() {
|
||||
await this.request("/api/delete_address", { method: "DELETE" })
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson(value: string) {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useId, useRef, useState } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
selector: string,
|
||||
options: {
|
||||
sitekey: string
|
||||
theme: "light" | "dark"
|
||||
callback: (token: string) => void
|
||||
},
|
||||
) => string
|
||||
remove: (id: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type TurnstileWidgetProps = {
|
||||
siteKey: string
|
||||
theme: "light" | "dark"
|
||||
onToken: (token: string) => void
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<void> | null = null
|
||||
|
||||
function loadTurnstileScript() {
|
||||
if (window.turnstile) return Promise.resolve()
|
||||
if (scriptPromise) return scriptPromise
|
||||
scriptPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]")
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve(), { once: true })
|
||||
existing.addEventListener("error", () => reject(new Error("Turnstile script failed")), { once: true })
|
||||
return
|
||||
}
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.dataset.turnstile = "true"
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error("Turnstile script failed"))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
return scriptPromise
|
||||
}
|
||||
|
||||
export function TurnstileWidget({ siteKey, theme, onToken }: TurnstileWidgetProps) {
|
||||
const id = `turnstile-${useId().replace(/:/g, "")}`
|
||||
const widgetId = useRef("")
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey) return undefined
|
||||
let mounted = true
|
||||
|
||||
const renderWidget = async () => {
|
||||
setFailed(false)
|
||||
onToken("")
|
||||
try {
|
||||
await loadTurnstileScript()
|
||||
if (!mounted || !window.turnstile) return
|
||||
if (widgetId.current) window.turnstile.remove(widgetId.current)
|
||||
widgetId.current = window.turnstile.render(`#${id}`, {
|
||||
sitekey: siteKey,
|
||||
theme,
|
||||
callback: onToken,
|
||||
})
|
||||
} catch {
|
||||
if (mounted) setFailed(true)
|
||||
}
|
||||
}
|
||||
|
||||
renderWidget()
|
||||
return () => {
|
||||
mounted = false
|
||||
if (widgetId.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetId.current)
|
||||
widgetId.current = ""
|
||||
}
|
||||
}
|
||||
}, [id, onToken, siteKey, theme])
|
||||
|
||||
if (!siteKey) return null
|
||||
|
||||
return (
|
||||
<div className="turnstile-box">
|
||||
<div id={id} />
|
||||
{failed && (
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
|
||||
Reload challenge
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
side === "bottom" &&
|
||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "./App"
|
||||
import "./index.css"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:8787",
|
||||
"/open_api": "http://127.0.0.1:8787",
|
||||
"/user_api": "http://127.0.0.1:8787",
|
||||
"/telegram": "http://127.0.0.1:8787",
|
||||
},
|
||||
},
|
||||
})
|
||||
+16
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudflare_temp_email",
|
||||
"version": "1.8.0",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -23,37 +23,37 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@unhead/vue": "^2.1.13",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@simplewebauthn/browser": "^13.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.15.1",
|
||||
"dompurify": "^3.4.0",
|
||||
"axios": "^1.19.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.4",
|
||||
"postal-mime": "^2.7.5",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.32",
|
||||
"vue": "^3.5.41",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.3.2",
|
||||
"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.6",
|
||||
"@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.2",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^3.2.4",
|
||||
"workbox-build": "^7.4.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"wrangler": "^4.83.0"
|
||||
"vitest": "^4.1.10",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1",
|
||||
"wrangler": "^4.119.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+1960
-2266
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,16 @@ onMounted(async () => {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) and (max-width: 1024px) {
|
||||
:where(input, textarea, select, [contenteditable="true"]) {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
:where(.n-input, .n-input-number, .n-base-selection, .n-input-group-label) {
|
||||
--n-font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -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 {
|
||||
@@ -104,7 +105,10 @@ const getOpenSettings = async (message, notification) => {
|
||||
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
|
||||
enableWebhook: res["enableWebhook"] || false,
|
||||
isS3Enabled: res["isS3Enabled"] || false,
|
||||
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
|
||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
|
||||
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
|
||||
statusUrl: res["statusUrl"] || "",
|
||||
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
||||
});
|
||||
@@ -120,7 +124,7 @@ const getOpenSettings = async (message, notification) => {
|
||||
notification.info({
|
||||
content: () => {
|
||||
return h("div", {
|
||||
innerHTML: announcement.value
|
||||
innerHTML: sanitizeHtml(announcement.value)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { useGlobalState } from '../store'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
jwt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
addressPassword: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show'])
|
||||
|
||||
const { openSettings, auth } = useGlobalState()
|
||||
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
|
||||
const message = useMessage()
|
||||
|
||||
const modalShow = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value),
|
||||
})
|
||||
|
||||
const configuredApiBaseUrl = import.meta.env.VITE_API_BASE || ''
|
||||
const frontendBaseUrl = computed(() => window.location.origin)
|
||||
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
|
||||
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
|
||||
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
|
||||
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
|
||||
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
|
||||
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
|
||||
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
|
||||
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
|
||||
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
|
||||
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
|
||||
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
|
||||
const securityLabel = computed(() =>
|
||||
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
|
||||
)
|
||||
const agentConfigJson = computed(() => JSON.stringify({
|
||||
base: apiBaseUrl.value,
|
||||
jwt: props.jwt,
|
||||
site_password: auth.value || '',
|
||||
}, null, 2))
|
||||
const agentText = computed(() => [
|
||||
`${t('currentAddress')}: ${props.address || '-'}`,
|
||||
`${t('apiBase')}: ${apiBaseUrl.value}`,
|
||||
`${t('agentSkill')}: ${agentSkillUrl}`,
|
||||
`${t('agentConfig')}:`,
|
||||
agentConfigJson.value,
|
||||
].join('\n'))
|
||||
const smtpImapText = computed(() => [
|
||||
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
|
||||
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
|
||||
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
|
||||
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
|
||||
`${t('security')}: ${securityLabel.value}`,
|
||||
`${t('username')}: ${props.address || '-'}`,
|
||||
`${t('password')}: ${props.jwt}`,
|
||||
].join('\n'))
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (!text) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success(t('copySuccess'))
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
try {
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
if (document.execCommand('copy')) {
|
||||
message.success(t('copySuccess'))
|
||||
return
|
||||
}
|
||||
message.error(t('copyFailed'))
|
||||
} finally {
|
||||
textarea.parentNode?.removeChild(textarea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
message.error(t('copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
|
||||
style="width: min(760px, calc(100vw - 32px));">
|
||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||
{{ t('tip') }}
|
||||
</n-alert>
|
||||
<section class="credential-panel">
|
||||
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
|
||||
<div class="credential-section">
|
||||
<div class="credential-field" v-if="address">
|
||||
<span class="credential-label">{{ t('currentAddress') }}</span>
|
||||
<div class="credential-copy-row">
|
||||
<code class="credential-code">{{ address }}</code>
|
||||
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
||||
<div class="credential-copy-row">
|
||||
<code class="credential-code">{{ jwt }}</code>
|
||||
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-field" v-if="addressPassword">
|
||||
<span class="credential-label">{{ t('addressPassword') }}</span>
|
||||
<code class="credential-code">{{ addressPassword }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<n-collapse accordion class="credential-collapse">
|
||||
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="credential-section">
|
||||
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('apiBase') }}</span>
|
||||
<code class="credential-code">{{ apiBaseUrl }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('agentSkill') }}</span>
|
||||
<code class="credential-code">
|
||||
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
|
||||
</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('agentConfig') }}</span>
|
||||
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||
{{ t('docs') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="credential-section">
|
||||
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
|
||||
<div class="credential-grid">
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('smtpHost') }}</span>
|
||||
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('smtpPort') }}</span>
|
||||
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('imapHost') }}</span>
|
||||
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('imapPort') }}</span>
|
||||
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('security') }}</span>
|
||||
<code class="credential-code">{{ securityLabel }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('username') }}</span>
|
||||
<code class="credential-code">{{ address }}</code>
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span class="credential-label">{{ t('password') }}</span>
|
||||
<code class="credential-code">{{ jwt }}</code>
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||
{{ t('docs') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
|
||||
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
|
||||
<template #header-extra>
|
||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
|
||||
{{ t('copySection') }}
|
||||
</n-button>
|
||||
</template>
|
||||
<div class="credential-section">
|
||||
<div class="credential-field">
|
||||
<code class="credential-code">{{ autoLoginUrl }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.credential-collapse {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.credential-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.credential-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.credential-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.credential-tip {
|
||||
margin: 0;
|
||||
color: var(--n-text-color-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.credential-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.credential-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credential-label {
|
||||
color: var(--n-text-color-2);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.credential-code {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--n-color-embedded);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.credential-copy-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credential-code-block {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.credential-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.credential-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.credential-copy-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -60,7 +60,7 @@ const props = defineProps({
|
||||
const localFilterKeyword = ref('')
|
||||
|
||||
const {
|
||||
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
|
||||
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
|
||||
autoRefresh, configAutoRefreshInterval, sendMailModel
|
||||
} = useGlobalState()
|
||||
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
|
||||
@@ -71,6 +71,12 @@ const count = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
const mailListPreviewLineClampValue = computed(() => {
|
||||
const value = Number(mailListPreviewLineClamp.value)
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(5, Math.max(0, Math.round(value)))
|
||||
})
|
||||
|
||||
// Computed property for filtered data (only filter current page)
|
||||
const data = computed(() => {
|
||||
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
|
||||
@@ -183,7 +189,7 @@ const refresh = async () => {
|
||||
count.value = totalCount;
|
||||
}
|
||||
curMail.value = null;
|
||||
if (!isMobile.value && data.value.length > 0) {
|
||||
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
|
||||
curMail.value = data.value[0];
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -202,6 +208,11 @@ const backFirstPageAndRefresh = async () => {
|
||||
const clickRow = async (row) => {
|
||||
if (multiActionMode.value) {
|
||||
row.checked = !row.checked;
|
||||
curMail.value = row;
|
||||
return;
|
||||
}
|
||||
if (mailListView.value && curMail.value?.id === row.id) {
|
||||
curMail.value = null;
|
||||
return;
|
||||
}
|
||||
curMail.value = row;
|
||||
@@ -375,8 +386,13 @@ onBeforeUnmount(() => {
|
||||
clearable />
|
||||
</n-space>
|
||||
</div>
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
|
||||
:on-update:size="onSpiltSizeChange">
|
||||
<n-split class="left" direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
|
||||
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange" v-if="!mailListView || curMail">
|
||||
<template #resize-trigger>
|
||||
<div class="split-handle">
|
||||
<div class="split-handle__grip" />
|
||||
</div>
|
||||
</template>
|
||||
<template #1>
|
||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||
<n-list hoverable clickable>
|
||||
@@ -412,15 +428,25 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
<template #2>
|
||||
<div v-if="curMail" style="margin: 8px;">
|
||||
<n-flex justify="space-between">
|
||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevMail') }}
|
||||
</n-button>
|
||||
<n-flex justify="space-between" align="center">
|
||||
<n-space :wrap="false" align="center">
|
||||
<n-button v-if="mailListView" @click="curMail = null" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('backToList') }}
|
||||
</n-button>
|
||||
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ArrowBackIosNewFilled />
|
||||
</n-icon>
|
||||
</template>
|
||||
{{ t('prevMail') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
@@ -446,6 +472,48 @@ onBeforeUnmount(() => {
|
||||
</n-card>
|
||||
</template>
|
||||
</n-split>
|
||||
<div v-else class="mail-list-scroll">
|
||||
<n-list hoverable clickable>
|
||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||
:class="mailItemClass(row)">
|
||||
<template #prefix v-if="multiActionMode">
|
||||
<n-checkbox v-model:checked="row.checked" />
|
||||
</template>
|
||||
<n-thing class="mail-list-thing">
|
||||
<template #header>
|
||||
<n-ellipsis class="mail-list-title">
|
||||
{{ row.subject }}
|
||||
</n-ellipsis>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="mail-list-meta">
|
||||
<n-tag type="info">
|
||||
ID: {{ row.id }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ utcToLocalDate(row.created_at, useUTCDate) }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
<n-ellipsis class="mail-list-meta-text">
|
||||
{{ showEMailTo ? "FROM: " + row.source : row.source }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<n-tag v-if="showEMailTo" type="info">
|
||||
<n-ellipsis class="mail-list-meta-text">
|
||||
TO: {{ row.address }}
|
||||
</n-ellipsis>
|
||||
</n-tag>
|
||||
<AiExtractInfo :metadata="row.metadata" compact />
|
||||
</div>
|
||||
</template>
|
||||
<n-ellipsis v-if="row.text && mailListPreviewLineClampValue > 0"
|
||||
:line-clamp="mailListPreviewLineClampValue" class="mail-list-preview" :tooltip="false">
|
||||
{{ row.text }}
|
||||
</n-ellipsis>
|
||||
</n-thing>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
</div>
|
||||
<div class="left" v-else>
|
||||
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
|
||||
@@ -555,8 +623,80 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mail-list-scroll {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 60vh;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.mail-list-thing,
|
||||
.mail-list-title,
|
||||
.mail-list-preview {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-thing,
|
||||
.mail-list-preview {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-thing :deep(.n-thing-main),
|
||||
.mail-list-thing :deep(.n-thing-header),
|
||||
.mail-list-thing :deep(.n-thing-header__title),
|
||||
.mail-list-thing :deep(.n-thing-main__description),
|
||||
.mail-list-thing :deep(.n-thing-main__content) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail-list-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-meta :deep(.n-tag) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mail-list-meta-text {
|
||||
max-width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.mail-list-preview {
|
||||
display: -webkit-box;
|
||||
overflow-wrap: anywhere;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mail-list-scroll :deep(.n-list-item__main) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.split-handle {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.split-handle__grip {
|
||||
width: 4px;
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--n-resize-trigger-color);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.split-handle:hover .split-handle__grip {
|
||||
background-color: var(--n-resize-trigger-color-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
|
||||
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound, ImageRound } from '@vicons/material'
|
||||
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
|
||||
import AiExtractInfo from "./AiExtractInfo.vue";
|
||||
import { getDownloadEmlUrl } from '../utils/email-parser';
|
||||
import { blockRemoteContent } from '../utils/remote-content-policy';
|
||||
import { utcToLocalDate } from '../utils';
|
||||
import { useGlobalState } from '../store';
|
||||
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
|
||||
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark, autoLoadRemoteImages } = useGlobalState();
|
||||
|
||||
const { t } = useScopedI18n('components.MailContentRenderer')
|
||||
|
||||
@@ -58,6 +59,26 @@ const curAttachments = ref([]);
|
||||
const attachmentLoding = ref(false);
|
||||
const showFullscreen = ref(false);
|
||||
|
||||
// Per-mail consent, deliberately independent of the global setting: it only
|
||||
// ever turns true when the user clicks "load images" for this specific mail,
|
||||
// and resets when a different mail is shown.
|
||||
const showRemoteImages = ref(false);
|
||||
watch(() => props.mail.id, () => {
|
||||
showRemoteImages.value = false;
|
||||
});
|
||||
|
||||
const processedMail = computed(() => {
|
||||
if (autoLoadRemoteImages.value || showRemoteImages.value) {
|
||||
return { message: props.mail.message, blocked: 0 };
|
||||
}
|
||||
const { html, blocked } = blockRemoteContent(props.mail.message);
|
||||
return { message: html, blocked };
|
||||
});
|
||||
|
||||
const handleLoadRemoteImages = () => {
|
||||
showRemoteImages.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
props.onDelete();
|
||||
};
|
||||
@@ -149,17 +170,32 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
</template>
|
||||
{{ t('fullscreen') }}
|
||||
</n-button>
|
||||
|
||||
</n-space>
|
||||
|
||||
<!-- 外部资源阻断提示 -->
|
||||
<n-alert v-if="processedMail.blocked" type="warning" :show-icon="false" :bordered="false"
|
||||
class="remote-images-banner">
|
||||
<n-space align="center" justify="space-between">
|
||||
<span>{{ t('remoteImagesBlocked', { count: processedMail.blocked }) }}</span>
|
||||
<n-button size="tiny" tertiary type="warning" @click="handleLoadRemoteImages">
|
||||
<template #icon>
|
||||
<n-icon :component="ImageRound" />
|
||||
</template>
|
||||
{{ t('loadRemoteImages') }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-alert>
|
||||
|
||||
<!-- AI 提取信息 -->
|
||||
<AiExtractInfo :metadata="mail.metadata" />
|
||||
|
||||
<!-- 邮件内容 -->
|
||||
<div class="mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -168,9 +204,9 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
<n-drawer-content :title="mail.subject" closable>
|
||||
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
|
||||
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
|
||||
<iframe v-else-if="useIframeShowMail" :srcdoc="processedMail.message" class="mail-iframe">
|
||||
</iframe>
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
|
||||
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="processedMail.message" :isDark="isDark" class="mail-html" />
|
||||
</div>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
@@ -215,6 +251,11 @@ const handleSaveToS3 = async (filename, blob) => {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Let the banner's inner space fill the alert so the button sits on the right. */
|
||||
.remote-images-banner :deep(.n-space) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mail-content {
|
||||
margin-top: 10px;
|
||||
flex: 1;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,10 +5,10 @@ export const deMessages = {
|
||||
"views.Index.about": "Über",
|
||||
"views.Admin.about": "Über",
|
||||
"views.Header.accessHeader": "Zugangspasswort",
|
||||
"views.Admin.account": "Konto",
|
||||
"views.Index.accountSettings": "Kontoeinstellungen",
|
||||
"views.Admin.account_settings": "Kontoeinstellungen",
|
||||
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
|
||||
"views.Admin.account": "E-Mail-Adressen",
|
||||
"views.Index.accountSettings": "E-Mail-Adress-Einstellungen",
|
||||
"views.Admin.account_settings": "E-Mail-Adress-Einstellungen",
|
||||
"views.index.SimpleIndex.accountSettings": "E-Mail-Adress-Einstellungen",
|
||||
"views.index.Attachment.action": "Aktion",
|
||||
"views.admin.SenderAccess.action": "Aktion",
|
||||
"views.user.UserSettings.actions": "Aktionen",
|
||||
@@ -60,13 +60,13 @@ export const deMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
|
||||
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
|
||||
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du wirklich dein Konto und alle zugehörigen E-Mails löschen?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen?",
|
||||
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
|
||||
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
"views.admin.AccountSettings.delete_rule_confirm": "Möchtest du diese Regel wirklich löschen?",
|
||||
"views.admin.UserManagement.deleteUserTip": "Möchtest du diesen Benutzer wirklich löschen?",
|
||||
"views.admin.UserManagement.deleteUserTip": "Möchtest du dieses Benutzerkonto wirklich löschen?",
|
||||
"views.Admin.logoutConfirmContent": "Möchtest du dich wirklich aus dem Admin-Bereich abmelden?",
|
||||
"views.user.UserSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
|
||||
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN-Organisationssperrliste",
|
||||
@@ -74,6 +74,7 @@ export const deMessages = {
|
||||
"components.AiExtractInfo.authLink": "Authentifizierungslink",
|
||||
"views.admin.Maintenance.autoCleanup": "Automatische Bereinigung",
|
||||
"components.MailBox.autoRefresh": "Automatische Aktualisierung",
|
||||
"components.MailBox.backToList": "Zurück zur Liste",
|
||||
"views.common.Appearance.autoRefreshInterval": "Automatisches Aktualisierungsintervall (s)",
|
||||
"views.Index.auto_reply": "Automatische Antwort",
|
||||
"views.index.AutoReply.autoReply": "Automatische Antwort",
|
||||
@@ -109,7 +110,7 @@ export const deMessages = {
|
||||
"views.admin.Maintenance.inactiveAddressLabel": "Inaktive Adressen löschen, die älter als n Tage sind",
|
||||
"views.admin.Maintenance.mailBoxLabel": "Posteingänge löschen, die älter als n Tage sind",
|
||||
"views.admin.Maintenance.sendBoxLabel": "Postausgänge löschen, die älter als n Tage sind",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "Nicht verknüpfte Adressen löschen, die älter als n Tage sind",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "Seit n Tagen nicht verknüpfte E-Mail-Adressen löschen",
|
||||
"views.admin.Maintenance.mailUnknowLabel": "E-Mails mit unbekanntem Empfänger löschen, die älter als n Tage sind",
|
||||
"views.index.AccountSettings.clearInbox": "Posteingang leeren",
|
||||
"views.admin.Account.clearInbox": "Posteingang leeren",
|
||||
@@ -133,20 +134,20 @@ export const deMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Kopieren",
|
||||
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
|
||||
"views.Footer.copyright": "Urheberrecht",
|
||||
"views.Admin.account_create": "Konto erstellen",
|
||||
"views.Admin.account_create": "E-Mail-Adresse erstellen",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
|
||||
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
|
||||
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
|
||||
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
|
||||
"views.user.UserSettings.createPasskey": "Passkey erstellen",
|
||||
"views.admin.UserManagement.createUser": "Benutzer erstellen",
|
||||
"views.admin.UserManagement.createUser": "Benutzerkonto erstellen",
|
||||
"views.user.UserSettings.created_at": "Erstellt am",
|
||||
"views.admin.Account.created_at": "Erstellt am",
|
||||
"views.admin.SenderAccess.created_at": "Erstellt am",
|
||||
"views.admin.UserManagement.created_at": "Erstellt am",
|
||||
"views.common.Login.credentialLogin": "Mit Zugangsdaten anmelden",
|
||||
"views.admin.DatabaseManager.current_db_version": "Aktuelle DB-Version",
|
||||
"views.user.UserBar.currentUser": "Aktuell angemeldeter Benutzer",
|
||||
"views.user.UserBar.currentUser": "Aktuelles Benutzerkonto",
|
||||
"views.admin.UserManagement.roleDonotExist": "Die aktuelle Rolle existiert nicht",
|
||||
"views.admin.Maintenance.customSqlCleanup": "Benutzerdefinierte SQL-Bereinigung",
|
||||
"views.admin.AccountSettings.send_mail_daily_limit": "Tageslimit",
|
||||
@@ -169,11 +170,11 @@ export const deMessages = {
|
||||
"views.admin.UserManagement.delete": "Löschen",
|
||||
"views.admin.AccountSettings.delete_rule": "Löschen",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Löschen",
|
||||
"views.index.AccountSettings.deleteAccount": "Konto löschen",
|
||||
"views.admin.Account.deleteAccount": "Konto löschen",
|
||||
"views.index.AccountSettings.deleteAccount": "E-Mail-Adresse löschen",
|
||||
"views.admin.Account.deleteAccount": "E-Mail-Adresse löschen",
|
||||
"views.user.UserSettings.deletePasskey": "Passkey löschen",
|
||||
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
|
||||
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
|
||||
"views.admin.UserManagement.deleteUser": "Benutzerkonto löschen",
|
||||
"views.index.Attachment.deleteSuccess": "Erfolgreich gelöscht",
|
||||
"views.admin.SenderAccess.disable": "Deaktivieren",
|
||||
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
|
||||
@@ -206,7 +207,7 @@ export const deMessages = {
|
||||
"views.admin.Telegram.enable": "Aktivieren",
|
||||
"views.admin.UserSettings.enable": "Aktivieren",
|
||||
"views.admin.AiExtractSettings.enableAllowList": "Aktivieren Adressfreigabeliste",
|
||||
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte Benutzer beschränken)",
|
||||
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte E-Mail-Adressen beschränken)",
|
||||
"views.index.AutoReply.enableAutoReply": "Automatische Antwort aktivieren",
|
||||
"views.admin.Maintenance.cronTip": "Um die Cron-Bereinigung zu aktivieren, konfiguriere [crons] im Worker. Siehe Dokumentation; 0 Tage bedeutet alles löschen.",
|
||||
"views.admin.IpBlacklistSettings.enable_daily_limit": "Aktivieren Tägliches Anfrage-Limit",
|
||||
@@ -299,7 +300,10 @@ export const deMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "E-Mail erfolgreich gelöscht",
|
||||
"views.user.UserLogin.cannotForgotPassword": "E-Mail-Verifizierung oder Registrierung ist deaktiviert; das Passwort kann nicht zurückgesetzt werden. Bitte den Administrator kontaktieren.",
|
||||
"views.Admin.mailWebhook": "Mail-Webhook",
|
||||
"views.common.Appearance.mailboxSplitSize": "Größe der Mailbox-Aufteilung",
|
||||
"views.common.Appearance.mailboxSplitSize": "Breite der linken Liste in der zweispaltigen Postfachansicht",
|
||||
"views.common.Appearance.mailListView": "Postfach-Listenansicht in voller Breite",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "Zeilen der Textvorschau",
|
||||
"views.common.Appearance.off": "Aus",
|
||||
"views.index.SimpleIndex.refreshSuccess": "E-Mails erfolgreich aktualisiert",
|
||||
"views.Admin.unknow": "E-Mails mit unbekanntem Empfänger",
|
||||
"views.Admin.maintenance": "Wartung",
|
||||
@@ -330,7 +334,7 @@ export const deMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Adressliste ohne Guthabenlimit",
|
||||
"views.index.SimpleIndex.noMails": "Keine E-Mails gefunden",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "In der Systemkonfiguration sind keine Rollen verfügbar",
|
||||
"views.index.SendMail.requestAccessTip": "Noch kein Sendeguthaben vorhanden. Wenn der Administrator ein Standardguthaben aktiviert hat, wird es automatisch zugewiesen; andernfalls Zugriff anfordern oder den Administrator kontaktieren.",
|
||||
"views.index.SendMail.requestAccessTip": "Sendezugang und Guthaben gehören zur aktuellen E-Mail-Adresse, nicht zum Benutzerkonto. Für diese Adresse Zugriff anfordern oder den Administrator kontaktieren.",
|
||||
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
|
||||
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
|
||||
@@ -416,7 +420,7 @@ export const deMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailReplace": "Ersetzungsvorlage",
|
||||
"components.MailBox.reply": "Antworten",
|
||||
"components.MailContentRenderer.reply": "Antworten",
|
||||
"views.index.SendMail.requestAccess": "Zugriff anfordern",
|
||||
"views.index.SendMail.requestAccess": "Zugriff für diese Adresse anfordern",
|
||||
"views.user.UserLogin.resetPassword": "Zurücksetzen Passwort",
|
||||
"views.admin.Account.resetPassword": "Zurücksetzen Passwort",
|
||||
"views.admin.UserManagement.resetPassword": "Zurücksetzen Passwort",
|
||||
@@ -552,15 +556,15 @@ export const deMessages = {
|
||||
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
|
||||
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
|
||||
"views.Header.user": "Benutzer",
|
||||
"views.Admin.user": "Benutzer",
|
||||
"components.AddressSelect.userAddresses": "Benutzeradressen",
|
||||
"views.Admin.user": "Benutzerkonten",
|
||||
"components.AddressSelect.userAddresses": "Mit Benutzerkonto verknüpfte Adressen",
|
||||
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
|
||||
"views.admin.Statistics.userCount": "Benutzeranzahl",
|
||||
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
|
||||
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
|
||||
"views.Admin.user_management": "Benutzerverwaltung",
|
||||
"views.Admin.user_settings": "Benutzereinstellungen",
|
||||
"views.User.user_settings": "Benutzereinstellungen",
|
||||
"views.admin.UserManagement.user_email": "E-Mail des Benutzerkontos",
|
||||
"views.index.AddressBar.userLogin": "Benutzerkonto-Anmeldung",
|
||||
"views.Admin.user_management": "Benutzerkonten verwalten",
|
||||
"views.Admin.user_settings": "Benutzerkonto-Einstellungen",
|
||||
"views.User.user_settings": "Benutzerkonto-Einstellungen",
|
||||
"components.AiExtractInfo.authCode": "Bestätigungscode",
|
||||
"views.user.UserLogin.verifyCode": "Bestätigungscode",
|
||||
"views.user.UserLogin.verifyCodeSent": "Bestätigungscode gesendet, läuft ab in {timeout} Sekunden",
|
||||
@@ -577,8 +581,8 @@ export const deMessages = {
|
||||
"views.Admin.webhookSettings": "Webhook-Einstellungen",
|
||||
"views.admin.AiExtractSettings.disabledTip": "Wenn deaktiviert, verarbeitet die KI-Extraktion alle E-Mail-Adressen",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "Wenn aktiviert, verarbeitet die KI-Extraktion nur E-Mails an Adressen auf der Freigabeliste",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Subdomain-Adressen werden nur für den Empfang empfohlen.",
|
||||
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Subdomain-Adressen werden nur für den Empfang empfohlen.",
|
||||
"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.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
|
||||
"views.admin.AiExtractSettings.allowListTip": "Der Platzhalter * passt auf beliebige Zeichen; z. B. passt *{'@'}example.com auf alle Adressen der Domain example.com",
|
||||
"views.Admin.workerconfig": "Worker-Konfiguration",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "Die Worker-Umgebungsvariable ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH ist derzeit false. Der gespeicherte Admin-Schalter kann geändert werden, wird aber erst wirksam, wenn die Umgebungsvariable aktiviert oder entfernt wird.",
|
||||
@@ -586,5 +590,32 @@ export const deMessages = {
|
||||
"views.admin.AccountSettings.tip": "Die folgenden Mehrfachauswahlwerte können manuell eingegeben und mit Enter hinzugefügt werden",
|
||||
"components.MailBox.emptyInbox": "Dein Posteingang ist leer",
|
||||
"views.index.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
|
||||
"views.admin.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden"
|
||||
"views.admin.SendMail.fromName": "Dein Name und deine Adresse; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
|
||||
"components.AddressCredentialModal.addressCredential": "Adresszugangsdaten",
|
||||
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
|
||||
"components.AddressCredentialModal.addressPassword": "Adresspasswort",
|
||||
"components.AddressCredentialModal.agentAccess": "AI Agent",
|
||||
"components.AddressCredentialModal.agentAccessTip": "Verwende dieses Postfach in einem AI Agent mit dem Address JWT und den parsed-mail APIs.",
|
||||
"components.AddressCredentialModal.agentConfig": "Agent-Konfiguration",
|
||||
"components.AddressCredentialModal.agentSkill": "Agent skill",
|
||||
"components.AddressCredentialModal.apiBase": "API-Basisadresse",
|
||||
"components.AddressCredentialModal.autoLoginLink": "Auto-Login-Link",
|
||||
"components.AddressCredentialModal.copyFailed": "Kopieren fehlgeschlagen",
|
||||
"components.AddressCredentialModal.copySection": "Kopieren",
|
||||
"components.AddressCredentialModal.copySuccess": "Kopiert",
|
||||
"components.AddressCredentialModal.currentAddress": "Aktuelle Adresse",
|
||||
"components.AddressCredentialModal.docs": "Dokumentation",
|
||||
"components.AddressCredentialModal.imapHost": "IMAP-Host",
|
||||
"components.AddressCredentialModal.imapPort": "IMAP-Port",
|
||||
"components.AddressCredentialModal.password": "Passwort",
|
||||
"components.AddressCredentialModal.plainOrProxyTls": "Klartext oder Proxy-TLS",
|
||||
"components.AddressCredentialModal.security": "Sicherheit",
|
||||
"components.AddressCredentialModal.smtpHost": "SMTP-Host",
|
||||
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
|
||||
"components.AddressCredentialModal.smtpImapTip": "Verwende diese Werte in Mail-Clients, nachdem der Administrator den SMTP/IMAP-Proxy konfiguriert hat. Als Passwort kannst du den hier angezeigten Address JWT oder ein vorhandenes Adresspasswort verwenden.",
|
||||
"components.AddressCredentialModal.smtpPort": "SMTP-Port",
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||
"components.AddressCredentialModal.username": "Benutzername"
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ export const esMessages = {
|
||||
"views.Index.about": "Acerca de",
|
||||
"views.Admin.about": "Acerca de",
|
||||
"views.Header.accessHeader": "Contraseña de acceso",
|
||||
"views.Admin.account": "Cuenta",
|
||||
"views.Index.accountSettings": "Configuración de la cuenta",
|
||||
"views.Admin.account_settings": "Configuración de la cuenta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
|
||||
"views.Admin.account": "Direcciones de correo",
|
||||
"views.Index.accountSettings": "Configuración de la dirección",
|
||||
"views.Admin.account_settings": "Configuración de direcciones",
|
||||
"views.index.SimpleIndex.accountSettings": "Configuración de la dirección",
|
||||
"views.index.Attachment.action": "Acción",
|
||||
"views.admin.SenderAccess.action": "Acción",
|
||||
"views.user.UserSettings.actions": "Acciones",
|
||||
@@ -60,13 +60,13 @@ export const esMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
|
||||
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
|
||||
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar tu cuenta y todos sus correos?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección y todos sus correos?",
|
||||
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
|
||||
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
"views.admin.AccountSettings.delete_rule_confirm": "¿Seguro que quieres eliminar esta regla?",
|
||||
"views.admin.UserManagement.deleteUserTip": "¿Seguro que quieres eliminar este usuario?",
|
||||
"views.admin.UserManagement.deleteUserTip": "¿Seguro que quieres eliminar esta cuenta de usuario?",
|
||||
"views.Admin.logoutConfirmContent": "¿Seguro que quieres salir del panel de administración?",
|
||||
"views.user.UserSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
|
||||
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizaciones ASN",
|
||||
@@ -74,6 +74,7 @@ export const esMessages = {
|
||||
"components.AiExtractInfo.authLink": "Enlace de autenticación",
|
||||
"views.admin.Maintenance.autoCleanup": "Limpieza automática",
|
||||
"components.MailBox.autoRefresh": "Actualización automática",
|
||||
"components.MailBox.backToList": "Volver a la lista",
|
||||
"views.common.Appearance.autoRefreshInterval": "Intervalo de actualización automática (s)",
|
||||
"views.Index.auto_reply": "Respuesta automática",
|
||||
"views.index.AutoReply.autoReply": "Respuesta automática",
|
||||
@@ -109,7 +110,7 @@ export const esMessages = {
|
||||
"views.admin.Maintenance.inactiveAddressLabel": "Limpiar las direcciones inactivas de hace más de n días",
|
||||
"views.admin.Maintenance.mailBoxLabel": "Limpiar la bandeja de entrada de hace más de n días",
|
||||
"views.admin.Maintenance.sendBoxLabel": "Limpiar la bandeja de salida de hace más de n días",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "Limpiar las direcciones no vinculadas de hace más de n días",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "Limpiar direcciones desvinculadas desde hace n días",
|
||||
"views.admin.Maintenance.mailUnknowLabel": "Limpiar los correos con destinatario desconocido de hace más de n días",
|
||||
"views.index.AccountSettings.clearInbox": "Vaciar bandeja de entrada",
|
||||
"views.admin.Account.clearInbox": "Vaciar bandeja de entrada",
|
||||
@@ -133,20 +134,20 @@ export const esMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||
"components.AiExtractInfo.copyFailed": "Error al copiar",
|
||||
"views.Footer.copyright": "Derechos de autor",
|
||||
"views.Admin.account_create": "Crear cuenta",
|
||||
"views.Admin.account_create": "Crear dirección de correo",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
|
||||
"views.common.Login.getNewEmail": "Crear nuevo correo",
|
||||
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
|
||||
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
|
||||
"views.user.UserSettings.createPasskey": "Crear passkey",
|
||||
"views.admin.UserManagement.createUser": "Crear usuario",
|
||||
"views.admin.UserManagement.createUser": "Crear cuenta de usuario",
|
||||
"views.user.UserSettings.created_at": "Creado el",
|
||||
"views.admin.Account.created_at": "Creado el",
|
||||
"views.admin.SenderAccess.created_at": "Creado el",
|
||||
"views.admin.UserManagement.created_at": "Creado el",
|
||||
"views.common.Login.credentialLogin": "Inicio de sesión con credencial",
|
||||
"views.admin.DatabaseManager.current_db_version": "Versión actual de la BD",
|
||||
"views.user.UserBar.currentUser": "Usuario actual",
|
||||
"views.user.UserBar.currentUser": "Cuenta de usuario actual",
|
||||
"views.admin.UserManagement.roleDonotExist": "El rol actual no existe",
|
||||
"views.admin.Maintenance.customSqlCleanup": "Limpieza SQL personalizada",
|
||||
"views.admin.AccountSettings.send_mail_daily_limit": "Límite diario",
|
||||
@@ -169,11 +170,11 @@ export const esMessages = {
|
||||
"views.admin.UserManagement.delete": "Eliminar",
|
||||
"views.admin.AccountSettings.delete_rule": "Eliminar",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
|
||||
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
|
||||
"views.admin.Account.deleteAccount": "Eliminar cuenta",
|
||||
"views.index.AccountSettings.deleteAccount": "Eliminar dirección de correo",
|
||||
"views.admin.Account.deleteAccount": "Eliminar dirección de correo",
|
||||
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
|
||||
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
|
||||
"views.admin.UserManagement.deleteUser": "Eliminar cuenta de usuario",
|
||||
"views.index.Attachment.deleteSuccess": "Eliminado correctamente",
|
||||
"views.admin.SenderAccess.disable": "Deshabilitar",
|
||||
"views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada",
|
||||
@@ -206,7 +207,7 @@ export const esMessages = {
|
||||
"views.admin.Telegram.enable": "Habilitar",
|
||||
"views.admin.UserSettings.enable": "Habilitar",
|
||||
"views.admin.AiExtractSettings.enableAllowList": "Habilitar Lista blanca de direcciones",
|
||||
"views.admin.Webhook.enableAllowList": "Habilitar lista de permitidos (restringe el acceso del webhook a usuarios específicos)",
|
||||
"views.admin.Webhook.enableAllowList": "Habilitar lista de permitidos (restringe el webhook a direcciones específicas)",
|
||||
"views.index.AutoReply.enableAutoReply": "Habilitar respuesta automática",
|
||||
"views.admin.Maintenance.cronTip": "Para activar la limpieza por cron, configura [crons] en el worker. Consulta la documentación; 0 días significa limpiar todo.",
|
||||
"views.admin.IpBlacklistSettings.enable_daily_limit": "Habilitar Límite diario de solicitudes",
|
||||
@@ -299,7 +300,10 @@ export const esMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "Correo eliminado correctamente",
|
||||
"views.user.UserLogin.cannotForgotPassword": "La verificación por correo o el registro está desactivado; no se puede restablecer la contraseña. Contacta con el administrador.",
|
||||
"views.Admin.mailWebhook": "Webhook de correo",
|
||||
"views.common.Appearance.mailboxSplitSize": "Tamaño de división del buzón",
|
||||
"views.common.Appearance.mailboxSplitSize": "Ancho de la lista izquierda en la vista de buzón de dos columnas",
|
||||
"views.common.Appearance.mailListView": "Vista de lista del buzón de ancho completo",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "Líneas de vista previa del cuerpo",
|
||||
"views.common.Appearance.off": "Desactivado",
|
||||
"views.index.SimpleIndex.refreshSuccess": "Correos actualizados correctamente",
|
||||
"views.Admin.unknow": "Correos con destinatario desconocido",
|
||||
"views.Admin.maintenance": "Mantenimiento",
|
||||
@@ -330,7 +334,7 @@ export const esMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de direcciones sin límite de saldo",
|
||||
"views.index.SimpleIndex.noMails": "No se encontraron correos",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "No hay roles disponibles en la configuración del sistema",
|
||||
"views.index.SendMail.requestAccessTip": "Todavía no hay saldo de envío. Si el administrador activó un saldo por defecto, se asignará automáticamente; si no, solicita acceso o contacta con el administrador.",
|
||||
"views.index.SendMail.requestAccessTip": "El acceso y el saldo de envío pertenecen a la dirección actual, no a la cuenta de usuario. Solicita acceso para esta dirección o contacta con el administrador.",
|
||||
"components.SendBox.emptySent": "No hay correos enviados",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
|
||||
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
|
||||
@@ -416,7 +420,7 @@ export const esMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailReplace": "Plantilla de reemplazo",
|
||||
"components.MailBox.reply": "Responder",
|
||||
"components.MailContentRenderer.reply": "Responder",
|
||||
"views.index.SendMail.requestAccess": "Solicitar acceso",
|
||||
"views.index.SendMail.requestAccess": "Solicitar acceso para esta dirección",
|
||||
"views.user.UserLogin.resetPassword": "Restablecer Contraseña",
|
||||
"views.admin.Account.resetPassword": "Restablecer Contraseña",
|
||||
"views.admin.UserManagement.resetPassword": "Restablecer Contraseña",
|
||||
@@ -552,15 +556,15 @@ export const esMessages = {
|
||||
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
|
||||
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
|
||||
"views.Header.user": "Usuario",
|
||||
"views.Admin.user": "Usuario",
|
||||
"components.AddressSelect.userAddresses": "Direcciones del usuario",
|
||||
"views.Admin.user": "Cuentas de usuario",
|
||||
"components.AddressSelect.userAddresses": "Direcciones vinculadas a la cuenta",
|
||||
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
|
||||
"views.admin.Statistics.userCount": "Cantidad de usuarios",
|
||||
"views.admin.UserManagement.user_email": "Correo del usuario",
|
||||
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
|
||||
"views.Admin.user_management": "Gestión de usuarios",
|
||||
"views.Admin.user_settings": "Configuración de usuario",
|
||||
"views.User.user_settings": "Configuración de usuario",
|
||||
"views.admin.UserManagement.user_email": "Correo de la cuenta de usuario",
|
||||
"views.index.AddressBar.userLogin": "Inicio de sesión de la cuenta",
|
||||
"views.Admin.user_management": "Gestión de cuentas de usuario",
|
||||
"views.Admin.user_settings": "Configuración de cuentas de usuario",
|
||||
"views.User.user_settings": "Configuración de la cuenta de usuario",
|
||||
"components.AiExtractInfo.authCode": "Código de verificación",
|
||||
"views.user.UserLogin.verifyCode": "Código de verificación",
|
||||
"views.user.UserLogin.verifyCodeSent": "Código de verificación enviado, expira en {timeout} segundos",
|
||||
@@ -577,8 +581,8 @@ export const esMessages = {
|
||||
"views.Admin.webhookSettings": "Configuración de webhook",
|
||||
"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. Se recomienda usarlo solo para recibir.",
|
||||
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Se recomienda usarlo solo para recibir.",
|
||||
"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.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",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "La variable ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH está en false. Puedes guardar el interruptor, pero no tendrá efecto hasta habilitar o quitar la variable.",
|
||||
@@ -586,5 +590,32 @@ export const esMessages = {
|
||||
"views.admin.AccountSettings.tip": "Puedes introducir manualmente los siguientes valores y pulsar Enter para añadirlos",
|
||||
"components.MailBox.emptyInbox": "Tu bandeja de entrada está vacía",
|
||||
"views.index.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo",
|
||||
"views.admin.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo"
|
||||
"views.admin.SendMail.fromName": "Tu nombre y dirección; deja el nombre vacío para usar el correo",
|
||||
"components.AddressCredentialModal.addressCredential": "Credencial de dirección",
|
||||
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
|
||||
"components.AddressCredentialModal.addressPassword": "Contraseña de la dirección",
|
||||
"components.AddressCredentialModal.agentAccess": "AI Agent",
|
||||
"components.AddressCredentialModal.agentAccessTip": "Usa este buzón desde un AI Agent con el Address JWT y las APIs parsed-mail.",
|
||||
"components.AddressCredentialModal.agentConfig": "Configuración del agente",
|
||||
"components.AddressCredentialModal.agentSkill": "Agent skill",
|
||||
"components.AddressCredentialModal.apiBase": "Base de la API",
|
||||
"components.AddressCredentialModal.autoLoginLink": "Enlace de inicio automático",
|
||||
"components.AddressCredentialModal.copyFailed": "Error al copiar",
|
||||
"components.AddressCredentialModal.copySection": "Copiar",
|
||||
"components.AddressCredentialModal.copySuccess": "Copiado",
|
||||
"components.AddressCredentialModal.currentAddress": "Dirección actual",
|
||||
"components.AddressCredentialModal.docs": "Documentación",
|
||||
"components.AddressCredentialModal.imapHost": "Host IMAP",
|
||||
"components.AddressCredentialModal.imapPort": "Puerto IMAP",
|
||||
"components.AddressCredentialModal.password": "Contraseña",
|
||||
"components.AddressCredentialModal.plainOrProxyTls": "Texto plano o TLS del proxy",
|
||||
"components.AddressCredentialModal.security": "Seguridad",
|
||||
"components.AddressCredentialModal.smtpHost": "Host SMTP",
|
||||
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
|
||||
"components.AddressCredentialModal.smtpImapTip": "Usa estos valores en clientes de correo después de que el administrador configure el proxy SMTP/IMAP. Como contraseña puedes usar el Address JWT mostrado aquí o la contraseña de la dirección si la tienes.",
|
||||
"components.AddressCredentialModal.smtpPort": "Puerto SMTP",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ export const jaMessages = {
|
||||
"views.Index.about": "概要",
|
||||
"views.Admin.about": "概要",
|
||||
"views.Header.accessHeader": "アクセス用パスワード",
|
||||
"views.Admin.account": "アカウント",
|
||||
"views.Index.accountSettings": "アカウント設定",
|
||||
"views.Admin.account_settings": "アカウント設定",
|
||||
"views.index.SimpleIndex.accountSettings": "アカウント設定",
|
||||
"views.Admin.account": "メールアドレス",
|
||||
"views.Index.accountSettings": "メールアドレス設定",
|
||||
"views.Admin.account_settings": "メールアドレス設定",
|
||||
"views.index.SimpleIndex.accountSettings": "メールアドレス設定",
|
||||
"views.index.Attachment.action": "操作",
|
||||
"views.admin.SenderAccess.action": "操作",
|
||||
"views.user.UserSettings.actions": "操作",
|
||||
@@ -60,13 +60,13 @@ export const jaMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
|
||||
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
|
||||
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連メールをすべて削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
|
||||
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
"views.admin.AccountSettings.delete_rule_confirm": "このルールを削除してもよろしいですか?",
|
||||
"views.admin.UserManagement.deleteUserTip": "このユーザーを削除してもよろしいですか?",
|
||||
"views.admin.UserManagement.deleteUserTip": "このユーザーアカウントを削除してもよろしいですか?",
|
||||
"views.Admin.logoutConfirmContent": "管理画面からログアウトしてもよろしいですか?",
|
||||
"views.user.UserSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
|
||||
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN組織ブラックリスト",
|
||||
@@ -74,6 +74,7 @@ export const jaMessages = {
|
||||
"components.AiExtractInfo.authLink": "認証リンク",
|
||||
"views.admin.Maintenance.autoCleanup": "自動クリーンアップ",
|
||||
"components.MailBox.autoRefresh": "自動更新",
|
||||
"components.MailBox.backToList": "リストに戻る",
|
||||
"views.common.Appearance.autoRefreshInterval": "自動更新間隔 (秒)",
|
||||
"views.Index.auto_reply": "自動返信",
|
||||
"views.index.AutoReply.autoReply": "自動返信",
|
||||
@@ -109,7 +110,7 @@ export const jaMessages = {
|
||||
"views.admin.Maintenance.inactiveAddressLabel": "n 日より前の非アクティブなアドレスを削除",
|
||||
"views.admin.Maintenance.mailBoxLabel": "n 日より前の受信箱を削除",
|
||||
"views.admin.Maintenance.sendBoxLabel": "n 日より前の送信箱を削除",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "n 日より前の未紐付けアドレスを削除",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "紐付け解除から n 日経過したメールアドレスを削除",
|
||||
"views.admin.Maintenance.mailUnknowLabel": "n 日より前の受信者不明メールを削除",
|
||||
"views.index.AccountSettings.clearInbox": "受信箱を削除",
|
||||
"views.admin.Account.clearInbox": "受信箱を削除",
|
||||
@@ -133,20 +134,20 @@ export const jaMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "コピー",
|
||||
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
|
||||
"views.Footer.copyright": "著作権",
|
||||
"views.Admin.account_create": "アカウントを作成",
|
||||
"views.Admin.account_create": "メールアドレスを作成",
|
||||
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
|
||||
"views.common.Login.getNewEmail": "新しいメールを作成",
|
||||
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
|
||||
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
|
||||
"views.user.UserSettings.createPasskey": "パスキーを作成",
|
||||
"views.admin.UserManagement.createUser": "ユーザーを作成",
|
||||
"views.admin.UserManagement.createUser": "ユーザーアカウントを作成",
|
||||
"views.user.UserSettings.created_at": "作成日時",
|
||||
"views.admin.Account.created_at": "作成日時",
|
||||
"views.admin.SenderAccess.created_at": "作成日時",
|
||||
"views.admin.UserManagement.created_at": "作成日時",
|
||||
"views.common.Login.credentialLogin": "資格情報でログイン",
|
||||
"views.admin.DatabaseManager.current_db_version": "現在のDBバージョン",
|
||||
"views.user.UserBar.currentUser": "現在のログインユーザー",
|
||||
"views.user.UserBar.currentUser": "現在のユーザーアカウント",
|
||||
"views.admin.UserManagement.roleDonotExist": "現在のロールは存在しません",
|
||||
"views.admin.Maintenance.customSqlCleanup": "カスタムSQLクリーンアップ",
|
||||
"views.admin.AccountSettings.send_mail_daily_limit": "日次上限",
|
||||
@@ -169,11 +170,11 @@ export const jaMessages = {
|
||||
"views.admin.UserManagement.delete": "削除",
|
||||
"views.admin.AccountSettings.delete_rule": "削除",
|
||||
"views.admin.Maintenance.deleteCustomSql": "削除",
|
||||
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
|
||||
"views.admin.Account.deleteAccount": "アカウントを削除",
|
||||
"views.index.AccountSettings.deleteAccount": "メールアドレスを削除",
|
||||
"views.admin.Account.deleteAccount": "メールアドレスを削除",
|
||||
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
|
||||
"views.admin.AccountSettings.delete_success": "削除しました",
|
||||
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
|
||||
"views.admin.UserManagement.deleteUser": "ユーザーアカウントを削除",
|
||||
"views.index.Attachment.deleteSuccess": "正常に削除しました",
|
||||
"views.admin.SenderAccess.disable": "無効化",
|
||||
"views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化",
|
||||
@@ -206,7 +207,7 @@ export const jaMessages = {
|
||||
"views.admin.Telegram.enable": "有効化",
|
||||
"views.admin.UserSettings.enable": "有効化",
|
||||
"views.admin.AiExtractSettings.enableAllowList": "アドレス許可リストを有効化",
|
||||
"views.admin.Webhook.enableAllowList": "許可リストを有効化 (Webhook へのアクセスを特定ユーザーに制限)",
|
||||
"views.admin.Webhook.enableAllowList": "許可リストを有効化(Webhook を特定のメールアドレスに制限)",
|
||||
"views.index.AutoReply.enableAutoReply": "自動返信を有効化",
|
||||
"views.admin.Maintenance.cronTip": "cron クリーンアップを有効にするには worker の [crons] を設定してください。詳細はドキュメントを参照してください。0 日はすべて削除を意味します。",
|
||||
"views.admin.IpBlacklistSettings.enable_daily_limit": "1日のリクエスト上限を有効化",
|
||||
@@ -299,7 +300,10 @@ export const jaMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "メールを削除しました",
|
||||
"views.user.UserLogin.cannotForgotPassword": "メール検証または登録が無効のため、パスワードを再設定できません。管理者へ連絡してください。",
|
||||
"views.Admin.mailWebhook": "メールWebhook",
|
||||
"views.common.Appearance.mailboxSplitSize": "メールボックス分割サイズ",
|
||||
"views.common.Appearance.mailboxSplitSize": "メールボックス2カラム表示の左側リスト幅",
|
||||
"views.common.Appearance.mailListView": "メールボックス全幅リスト表示",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "本文プレビュー行数",
|
||||
"views.common.Appearance.off": "オフ",
|
||||
"views.index.SimpleIndex.refreshSuccess": "メールを更新しました",
|
||||
"views.Admin.unknow": "受信者不明のメール",
|
||||
"views.Admin.maintenance": "メンテナンス",
|
||||
@@ -330,7 +334,7 @@ export const jaMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
|
||||
"views.index.SimpleIndex.noMails": "メールが見つかりません",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
|
||||
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者へ連絡してください。",
|
||||
"views.index.SendMail.requestAccessTip": "送信権限と残高はユーザーアカウントではなく現在のメールアドレスに属します。このアドレスの権限を申請するか、管理者に連絡してください。",
|
||||
"components.SendBox.emptySent": "送信済みメールはありません",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
|
||||
"views.Admin.userOauth2Settings": "OAuth2設定",
|
||||
@@ -416,7 +420,7 @@ export const jaMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailReplace": "置換テンプレート",
|
||||
"components.MailBox.reply": "返信",
|
||||
"components.MailContentRenderer.reply": "返信",
|
||||
"views.index.SendMail.requestAccess": "アクセスを申請",
|
||||
"views.index.SendMail.requestAccess": "このアドレスの送信権限を申請",
|
||||
"views.user.UserLogin.resetPassword": "パスワードをリセット",
|
||||
"views.admin.Account.resetPassword": "パスワードをリセット",
|
||||
"views.admin.UserManagement.resetPassword": "パスワードをリセット",
|
||||
@@ -552,15 +556,15 @@ export const jaMessages = {
|
||||
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
|
||||
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
|
||||
"views.Header.user": "ユーザー",
|
||||
"views.Admin.user": "ユーザー",
|
||||
"components.AddressSelect.userAddresses": "ユーザーのアドレス",
|
||||
"views.Admin.user": "ユーザーアカウント",
|
||||
"components.AddressSelect.userAddresses": "ユーザーアカウントに紐付くアドレス",
|
||||
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
|
||||
"views.admin.Statistics.userCount": "ユーザー数",
|
||||
"views.admin.UserManagement.user_email": "ユーザーメール",
|
||||
"views.index.AddressBar.userLogin": "ユーザーログイン",
|
||||
"views.Admin.user_management": "ユーザー管理",
|
||||
"views.Admin.user_settings": "ユーザー設定",
|
||||
"views.User.user_settings": "ユーザー設定",
|
||||
"views.admin.UserManagement.user_email": "ユーザーアカウントのメール",
|
||||
"views.index.AddressBar.userLogin": "ユーザーアカウントログイン",
|
||||
"views.Admin.user_management": "ユーザーアカウント管理",
|
||||
"views.Admin.user_settings": "ユーザーアカウント設定",
|
||||
"views.User.user_settings": "ユーザーアカウント設定",
|
||||
"components.AiExtractInfo.authCode": "認証コード",
|
||||
"views.user.UserLogin.verifyCode": "認証コード",
|
||||
"views.user.UserLogin.verifyCodeSent": "認証コードを送信しました, 有効期限 {timeout} 秒",
|
||||
@@ -577,8 +581,8 @@ export const jaMessages = {
|
||||
"views.Admin.webhookSettings": "Webhook設定",
|
||||
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。サブドメインアドレスは受信専用として推奨されます。",
|
||||
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。サブドメインアドレスは受信専用として推奨されます。",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
|
||||
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
|
||||
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
|
||||
"views.Admin.workerconfig": "Worker設定",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "Worker 環境変数 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH は現在 false です。管理画面のスイッチは保存できますが、env を有効化または削除するまで反映されません。",
|
||||
@@ -586,5 +590,32 @@ export const jaMessages = {
|
||||
"views.admin.AccountSettings.tip": "以下の複数選択項目は手動入力して Enter で追加できます",
|
||||
"components.MailBox.emptyInbox": "受信箱は空です",
|
||||
"views.index.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します",
|
||||
"views.admin.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します"
|
||||
"views.admin.SendMail.fromName": "あなたの名前とアドレス。名前を空欄にするとメールアドレスを使用します",
|
||||
"components.AddressCredentialModal.addressCredential": "アドレス認証情報",
|
||||
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
|
||||
"components.AddressCredentialModal.addressPassword": "アドレスパスワード",
|
||||
"components.AddressCredentialModal.agentAccess": "AI Agent",
|
||||
"components.AddressCredentialModal.agentAccessTip": "AI Agent から Address JWT と parsed-mail API を使ってこのメールボックスを利用できます。",
|
||||
"components.AddressCredentialModal.agentConfig": "Agent 設定",
|
||||
"components.AddressCredentialModal.agentSkill": "Agent skill",
|
||||
"components.AddressCredentialModal.apiBase": "API ベース",
|
||||
"components.AddressCredentialModal.autoLoginLink": "自動ログインリンク",
|
||||
"components.AddressCredentialModal.copyFailed": "コピーに失敗しました",
|
||||
"components.AddressCredentialModal.copySection": "コピー",
|
||||
"components.AddressCredentialModal.copySuccess": "コピーしました",
|
||||
"components.AddressCredentialModal.currentAddress": "現在のアドレス",
|
||||
"components.AddressCredentialModal.docs": "ドキュメント",
|
||||
"components.AddressCredentialModal.imapHost": "IMAP ホスト",
|
||||
"components.AddressCredentialModal.imapPort": "IMAP ポート",
|
||||
"components.AddressCredentialModal.password": "パスワード",
|
||||
"components.AddressCredentialModal.plainOrProxyTls": "平文またはプロキシ側 TLS",
|
||||
"components.AddressCredentialModal.security": "セキュリティ",
|
||||
"components.AddressCredentialModal.smtpHost": "SMTP ホスト",
|
||||
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
|
||||
"components.AddressCredentialModal.smtpImapTip": "管理者が SMTP/IMAP プロキシを設定した後、メールクライアントでこれらの値を使用できます。パスワードにはここに表示される Address JWT、または手元にあるアドレスパスワードを使用できます。",
|
||||
"components.AddressCredentialModal.smtpPort": "SMTP ポート",
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||
"components.AddressCredentialModal.username": "ユーザー名"
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ export const ptBRMessages = {
|
||||
"views.Index.about": "Sobre",
|
||||
"views.Admin.about": "Sobre",
|
||||
"views.Header.accessHeader": "Senha de acesso",
|
||||
"views.Admin.account": "Conta",
|
||||
"views.Index.accountSettings": "Configurações da conta",
|
||||
"views.Admin.account_settings": "Configurações da conta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
|
||||
"views.Admin.account": "Endereços de e-mail",
|
||||
"views.Index.accountSettings": "Configurações do endereço",
|
||||
"views.Admin.account_settings": "Configurações de endereços",
|
||||
"views.index.SimpleIndex.accountSettings": "Configurações do endereço",
|
||||
"views.index.Attachment.action": "Ação",
|
||||
"views.admin.SenderAccess.action": "Ação",
|
||||
"views.user.UserSettings.actions": "Ações",
|
||||
@@ -60,13 +60,13 @@ export const ptBRMessages = {
|
||||
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
|
||||
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
|
||||
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir sua conta e todos os e-mails dela?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço e todos os e-mails dele?",
|
||||
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
|
||||
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
"views.admin.AccountSettings.delete_rule_confirm": "Tem certeza de que deseja excluir esta regra?",
|
||||
"views.admin.UserManagement.deleteUserTip": "Tem certeza de que deseja excluir este usuário?",
|
||||
"views.admin.UserManagement.deleteUserTip": "Tem certeza de que deseja excluir esta conta de usuário?",
|
||||
"views.Admin.logoutConfirmContent": "Tem certeza de que deseja sair do painel de administração?",
|
||||
"views.user.UserSettings.logoutConfirm": "Tem certeza de que deseja sair?",
|
||||
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizações ASN",
|
||||
@@ -74,6 +74,7 @@ export const ptBRMessages = {
|
||||
"components.AiExtractInfo.authLink": "Link de autenticação",
|
||||
"views.admin.Maintenance.autoCleanup": "Limpeza automática",
|
||||
"components.MailBox.autoRefresh": "Atualização automática",
|
||||
"components.MailBox.backToList": "Voltar para a lista",
|
||||
"views.common.Appearance.autoRefreshInterval": "Intervalo de atualização automática (s)",
|
||||
"views.Index.auto_reply": "Resposta automática",
|
||||
"views.index.AutoReply.autoReply": "Resposta automática",
|
||||
@@ -109,7 +110,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Maintenance.inactiveAddressLabel": "Limpar os endereços inativos de mais de n dias",
|
||||
"views.admin.Maintenance.mailBoxLabel": "Limpar a caixa de entrada de mais de n dias",
|
||||
"views.admin.Maintenance.sendBoxLabel": "Limpar a caixa de saída de mais de n dias",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "Limpar os endereços desvinculados de mais de n dias",
|
||||
"views.admin.Maintenance.unboundAddressLabel": "Limpar endereços desvinculados há n dias",
|
||||
"views.admin.Maintenance.mailUnknowLabel": "Limpar os e-mails com destinatário desconhecido de mais de n dias",
|
||||
"views.index.AccountSettings.clearInbox": "Limpar caixa de entrada",
|
||||
"views.admin.Account.clearInbox": "Limpar caixa de entrada",
|
||||
@@ -133,20 +134,20 @@ export const ptBRMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
|
||||
"views.Footer.copyright": "Direitos autorais",
|
||||
"views.Admin.account_create": "Criar conta",
|
||||
"views.Admin.account_create": "Criar endereço de e-mail",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
|
||||
"views.common.Login.getNewEmail": "Criar novo e-mail",
|
||||
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
|
||||
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
|
||||
"views.user.UserSettings.createPasskey": "Criar passkey",
|
||||
"views.admin.UserManagement.createUser": "Criar usuário",
|
||||
"views.admin.UserManagement.createUser": "Criar conta de usuário",
|
||||
"views.user.UserSettings.created_at": "Criado em",
|
||||
"views.admin.Account.created_at": "Criado em",
|
||||
"views.admin.SenderAccess.created_at": "Criado em",
|
||||
"views.admin.UserManagement.created_at": "Criado em",
|
||||
"views.common.Login.credentialLogin": "Login com credencial",
|
||||
"views.admin.DatabaseManager.current_db_version": "Versão atual do banco",
|
||||
"views.user.UserBar.currentUser": "Usuário atual",
|
||||
"views.user.UserBar.currentUser": "Conta de usuário atual",
|
||||
"views.admin.UserManagement.roleDonotExist": "A função atual não existe",
|
||||
"views.admin.Maintenance.customSqlCleanup": "Limpeza SQL personalizada",
|
||||
"views.admin.AccountSettings.send_mail_daily_limit": "Limite diário",
|
||||
@@ -169,11 +170,11 @@ export const ptBRMessages = {
|
||||
"views.admin.UserManagement.delete": "Excluir",
|
||||
"views.admin.AccountSettings.delete_rule": "Excluir",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Excluir",
|
||||
"views.index.AccountSettings.deleteAccount": "Excluir conta",
|
||||
"views.admin.Account.deleteAccount": "Excluir conta",
|
||||
"views.index.AccountSettings.deleteAccount": "Excluir endereço de e-mail",
|
||||
"views.admin.Account.deleteAccount": "Excluir endereço de e-mail",
|
||||
"views.user.UserSettings.deletePasskey": "Excluir passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
|
||||
"views.admin.UserManagement.deleteUser": "Excluir usuário",
|
||||
"views.admin.UserManagement.deleteUser": "Excluir conta de usuário",
|
||||
"views.index.Attachment.deleteSuccess": "Excluído com sucesso",
|
||||
"views.admin.SenderAccess.disable": "Desativar",
|
||||
"views.Admin.loginViaDisabledCheck": "Verificação de senha desativada",
|
||||
@@ -206,7 +207,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Telegram.enable": "Ativar",
|
||||
"views.admin.UserSettings.enable": "Ativar",
|
||||
"views.admin.AiExtractSettings.enableAllowList": "Ativar Lista branca de endereços",
|
||||
"views.admin.Webhook.enableAllowList": "Ativar lista de permissão (restringir o acesso ao webhook a usuários específicos)",
|
||||
"views.admin.Webhook.enableAllowList": "Ativar lista de permissão (restringir o webhook a endereços específicos)",
|
||||
"views.index.AutoReply.enableAutoReply": "Ativar resposta automática",
|
||||
"views.admin.Maintenance.cronTip": "Para ativar a limpeza por cron, configure [crons] no worker. Consulte a documentação; 0 dias significa limpar tudo.",
|
||||
"views.admin.IpBlacklistSettings.enable_daily_limit": "Ativar limite diário de solicitações",
|
||||
@@ -299,7 +300,10 @@ export const ptBRMessages = {
|
||||
"views.index.SimpleIndex.deleteSuccess": "E-mail excluído com sucesso",
|
||||
"views.user.UserLogin.cannotForgotPassword": "A verificação por e-mail ou o registro está desativado; não é possível redefinir a senha. Entre em contato com o administrador.",
|
||||
"views.Admin.mailWebhook": "Webhook de e-mail",
|
||||
"views.common.Appearance.mailboxSplitSize": "Tamanho da divisão da caixa de correio",
|
||||
"views.common.Appearance.mailboxSplitSize": "Largura da lista esquerda na visualização de duas colunas da caixa de correio",
|
||||
"views.common.Appearance.mailListView": "Visualização de lista da caixa de correio em largura total",
|
||||
"views.common.Appearance.mailListPreviewLineClamp": "Linhas da prévia do corpo",
|
||||
"views.common.Appearance.off": "Desativado",
|
||||
"views.index.SimpleIndex.refreshSuccess": "E-mails atualizados com sucesso",
|
||||
"views.Admin.unknow": "E-mails com destinatário desconhecido",
|
||||
"views.Admin.maintenance": "Manutenção",
|
||||
@@ -330,7 +334,7 @@ export const ptBRMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de endereços sem limite de saldo",
|
||||
"views.index.SimpleIndex.noMails": "Nenhum e-mail encontrado",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "Nenhuma função disponível na configuração do sistema",
|
||||
"views.index.SendMail.requestAccessTip": "Ainda não há saldo de envio. Se o administrador ativou um saldo padrão, ele será atribuído automaticamente; caso contrário, solicite acesso ou fale com o administrador.",
|
||||
"views.index.SendMail.requestAccessTip": "O acesso e o saldo de envio pertencem ao endereço atual, não à conta de usuário. Solicite acesso para este endereço ou fale com o administrador.",
|
||||
"components.SendBox.emptySent": "Nenhum e-mail enviado",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "Não configurado (usar configurações globais)",
|
||||
"views.Admin.userOauth2Settings": "Configurações de OAuth2",
|
||||
@@ -416,7 +420,7 @@ export const ptBRMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailReplace": "Modelo de substituição",
|
||||
"components.MailBox.reply": "Responder",
|
||||
"components.MailContentRenderer.reply": "Responder",
|
||||
"views.index.SendMail.requestAccess": "Solicitar acesso",
|
||||
"views.index.SendMail.requestAccess": "Solicitar acesso para este endereço",
|
||||
"views.user.UserLogin.resetPassword": "Redefinir Senha",
|
||||
"views.admin.Account.resetPassword": "Redefinir Senha",
|
||||
"views.admin.UserManagement.resetPassword": "Redefinir Senha",
|
||||
@@ -552,15 +556,15 @@ export const ptBRMessages = {
|
||||
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
|
||||
"views.common.Appearance.useUTCDate": "Usar data UTC",
|
||||
"views.Header.user": "Usuário",
|
||||
"views.Admin.user": "Usuário",
|
||||
"components.AddressSelect.userAddresses": "Endereços do usuário",
|
||||
"views.Admin.user": "Contas de usuário",
|
||||
"components.AddressSelect.userAddresses": "Endereços vinculados à conta",
|
||||
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
|
||||
"views.admin.Statistics.userCount": "Quantidade de usuários",
|
||||
"views.admin.UserManagement.user_email": "E-mail do usuário",
|
||||
"views.index.AddressBar.userLogin": "Login do usuário",
|
||||
"views.Admin.user_management": "Gerenciamento de usuários",
|
||||
"views.Admin.user_settings": "Configurações do usuário",
|
||||
"views.User.user_settings": "Configurações do usuário",
|
||||
"views.admin.UserManagement.user_email": "E-mail da conta de usuário",
|
||||
"views.index.AddressBar.userLogin": "Login da conta de usuário",
|
||||
"views.Admin.user_management": "Gerenciamento de contas de usuário",
|
||||
"views.Admin.user_settings": "Configurações de contas de usuário",
|
||||
"views.User.user_settings": "Configurações da conta de usuário",
|
||||
"components.AiExtractInfo.authCode": "Código de verificação",
|
||||
"views.user.UserLogin.verifyCode": "Código de verificação",
|
||||
"views.user.UserLogin.verifyCodeSent": "Código de verificação enviado, expira em {timeout} segundos",
|
||||
@@ -577,8 +581,8 @@ export const ptBRMessages = {
|
||||
"views.Admin.webhookSettings": "Configurações de webhook",
|
||||
"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. Endereços com subdomínio são recomendados apenas para recebimento.",
|
||||
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Endereços com subdomínio são recomendados apenas para recebimento.",
|
||||
"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.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",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "A variável ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH está em false. O botão salvo pode ser alterado, mas só terá efeito quando a variável for ativada ou removida.",
|
||||
@@ -586,5 +590,32 @@ export const ptBRMessages = {
|
||||
"views.admin.AccountSettings.tip": "Você pode inserir manualmente os seguintes valores e pressionar Enter para adicioná-los",
|
||||
"components.MailBox.emptyInbox": "Sua caixa de entrada está vazia",
|
||||
"views.index.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail",
|
||||
"views.admin.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail"
|
||||
"views.admin.SendMail.fromName": "Seu nome e endereço; deixe o nome em branco para usar o e-mail",
|
||||
"components.AddressCredentialModal.addressCredential": "Credencial do endereço",
|
||||
"components.AddressCredentialModal.addressCredentialLabel": "Address JWT",
|
||||
"components.AddressCredentialModal.addressPassword": "Senha do endereço",
|
||||
"components.AddressCredentialModal.agentAccess": "AI Agent",
|
||||
"components.AddressCredentialModal.agentAccessTip": "Use esta caixa de entrada em um AI Agent com o Address JWT e as APIs parsed-mail.",
|
||||
"components.AddressCredentialModal.agentConfig": "Configuração do Agent",
|
||||
"components.AddressCredentialModal.agentSkill": "Agent skill",
|
||||
"components.AddressCredentialModal.apiBase": "Base da API",
|
||||
"components.AddressCredentialModal.autoLoginLink": "Link de login automático",
|
||||
"components.AddressCredentialModal.copyFailed": "Falha ao copiar",
|
||||
"components.AddressCredentialModal.copySection": "Copiar",
|
||||
"components.AddressCredentialModal.copySuccess": "Copiado",
|
||||
"components.AddressCredentialModal.currentAddress": "Endereço atual",
|
||||
"components.AddressCredentialModal.docs": "Documentação",
|
||||
"components.AddressCredentialModal.imapHost": "Host IMAP",
|
||||
"components.AddressCredentialModal.imapPort": "Porta IMAP",
|
||||
"components.AddressCredentialModal.password": "Senha",
|
||||
"components.AddressCredentialModal.plainOrProxyTls": "Texto puro ou TLS do proxy",
|
||||
"components.AddressCredentialModal.security": "Segurança",
|
||||
"components.AddressCredentialModal.smtpHost": "Host SMTP",
|
||||
"components.AddressCredentialModal.smtpImapAccess": "SMTP / IMAP",
|
||||
"components.AddressCredentialModal.smtpImapTip": "Use estes valores em clientes de e-mail depois que o administrador configurar o proxy SMTP/IMAP. Como senha, use o Address JWT mostrado aqui ou a senha do endereço quando você a tiver.",
|
||||
"components.AddressCredentialModal.smtpPort": "Porta SMTP",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Auto Refresh",
|
||||
"zh": "自动刷新"
|
||||
},
|
||||
"backToList": {
|
||||
"en": "Back to List",
|
||||
"zh": "返回列表"
|
||||
},
|
||||
"cancelMultiAction": {
|
||||
"en": "Cancel Multi Action",
|
||||
"zh": "取消多选"
|
||||
@@ -186,6 +190,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Fullscreen",
|
||||
"zh": "全屏"
|
||||
},
|
||||
"loadRemoteImages": {
|
||||
"en": "Load Images",
|
||||
"zh": "加载图片"
|
||||
},
|
||||
"remoteImagesBlocked": {
|
||||
"en": "{count} remote resources blocked to protect your privacy",
|
||||
"zh": "已阻止 {count} 项外部资源以保护隐私"
|
||||
},
|
||||
"reply": {
|
||||
"en": "Reply",
|
||||
"zh": "回复"
|
||||
@@ -277,8 +289,118 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "本地地址"
|
||||
},
|
||||
"userAddresses": {
|
||||
"en": "User Addresses",
|
||||
"zh": "用户地址"
|
||||
"en": "Addresses Bound to User Account",
|
||||
"zh": "用户账号绑定地址"
|
||||
}
|
||||
},
|
||||
"components.AddressCredentialModal": {
|
||||
"addressCredential": {
|
||||
"en": "Address Credential",
|
||||
"zh": "地址凭证"
|
||||
},
|
||||
"addressCredentialLabel": {
|
||||
"en": "Address JWT",
|
||||
"zh": "Address JWT"
|
||||
},
|
||||
"addressPassword": {
|
||||
"en": "Address Password",
|
||||
"zh": "地址密码"
|
||||
},
|
||||
"agentAccess": {
|
||||
"en": "AI Agent",
|
||||
"zh": "AI Agent"
|
||||
},
|
||||
"agentAccessTip": {
|
||||
"en": "Use this mailbox from an AI agent with the Address JWT and parsed-mail APIs.",
|
||||
"zh": "AI Agent 可使用 Address JWT 和 parsed-mail API 读取这个邮箱。"
|
||||
},
|
||||
"agentConfig": {
|
||||
"en": "Agent config",
|
||||
"zh": "Agent 配置"
|
||||
},
|
||||
"agentSkill": {
|
||||
"en": "Agent skill",
|
||||
"zh": "Agent skill"
|
||||
},
|
||||
"apiBase": {
|
||||
"en": "API Base",
|
||||
"zh": "API 地址"
|
||||
},
|
||||
"autoLoginLink": {
|
||||
"en": "Auto-login link",
|
||||
"zh": "自动登录链接"
|
||||
},
|
||||
"copyFailed": {
|
||||
"en": "Copy failed",
|
||||
"zh": "复制失败"
|
||||
},
|
||||
"copySection": {
|
||||
"en": "Copy",
|
||||
"zh": "复制"
|
||||
},
|
||||
"copySuccess": {
|
||||
"en": "Copied",
|
||||
"zh": "已复制"
|
||||
},
|
||||
"currentAddress": {
|
||||
"en": "Current address",
|
||||
"zh": "当前邮箱"
|
||||
},
|
||||
"docs": {
|
||||
"en": "Docs",
|
||||
"zh": "文档"
|
||||
},
|
||||
"imapHost": {
|
||||
"en": "IMAP host",
|
||||
"zh": "IMAP 主机"
|
||||
},
|
||||
"imapPort": {
|
||||
"en": "IMAP port",
|
||||
"zh": "IMAP 端口"
|
||||
},
|
||||
"password": {
|
||||
"en": "Password",
|
||||
"zh": "密码"
|
||||
},
|
||||
"plainOrProxyTls": {
|
||||
"en": "Plain or proxy TLS",
|
||||
"zh": "明文或代理层 TLS"
|
||||
},
|
||||
"security": {
|
||||
"en": "Security",
|
||||
"zh": "安全"
|
||||
},
|
||||
"smtpHost": {
|
||||
"en": "SMTP host",
|
||||
"zh": "SMTP 主机"
|
||||
},
|
||||
"smtpImapAccess": {
|
||||
"en": "SMTP / IMAP",
|
||||
"zh": "SMTP / IMAP"
|
||||
},
|
||||
"smtpImapTip": {
|
||||
"en": "Use these values in mail clients after the administrator configures the SMTP/IMAP proxy. The password can be the Address JWT shown here, or the address password when you have it.",
|
||||
"zh": "管理员配置 SMTP/IMAP 代理后,可在邮件客户端中使用这些信息。密码可使用这里展示的 Address JWT,也可使用你持有的地址密码。"
|
||||
},
|
||||
"smtpPort": {
|
||||
"en": "SMTP port",
|
||||
"zh": "SMTP 端口"
|
||||
},
|
||||
"starttls": {
|
||||
"en": "STARTTLS",
|
||||
"zh": "STARTTLS"
|
||||
},
|
||||
"tip": {
|
||||
"en": "Use these credentials only with clients and agents you trust.",
|
||||
"zh": "请只在可信的客户端和 Agent 中使用这些凭证。"
|
||||
},
|
||||
"title": {
|
||||
"en": "Address Credentials & Connection Methods",
|
||||
"zh": "地址凭证与连接方式"
|
||||
},
|
||||
"username": {
|
||||
"en": "Username",
|
||||
"zh": "用户名"
|
||||
}
|
||||
},
|
||||
"views.user.UserMailBox": {
|
||||
@@ -297,8 +419,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "关于"
|
||||
},
|
||||
"accountSettings": {
|
||||
"en": "Account Settings",
|
||||
"zh": "账户"
|
||||
"en": "Mailbox Address Settings",
|
||||
"zh": "邮箱地址设置"
|
||||
},
|
||||
"appearance": {
|
||||
"en": "Appearance",
|
||||
@@ -443,8 +565,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "Cloudflare 临时邮件"
|
||||
},
|
||||
"user": {
|
||||
"en": "User",
|
||||
"zh": "用户"
|
||||
"en": "User Account",
|
||||
"zh": "用户账号"
|
||||
}
|
||||
},
|
||||
"views.user.BindAddress": {
|
||||
@@ -467,16 +589,16 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "请输入 Admin 密码"
|
||||
},
|
||||
"account": {
|
||||
"en": "Account",
|
||||
"zh": "账号"
|
||||
"en": "Mailbox Addresses",
|
||||
"zh": "邮箱地址"
|
||||
},
|
||||
"account_create": {
|
||||
"en": "Create Account",
|
||||
"zh": "创建账号"
|
||||
"en": "Create Mailbox Address",
|
||||
"zh": "创建邮箱地址"
|
||||
},
|
||||
"account_settings": {
|
||||
"en": "Account Settings",
|
||||
"zh": "账号设置"
|
||||
"en": "Mailbox Address Settings",
|
||||
"zh": "邮箱地址设置"
|
||||
},
|
||||
"adminAccount": {
|
||||
"en": "Admin",
|
||||
@@ -583,20 +705,20 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "无收件人邮件"
|
||||
},
|
||||
"user": {
|
||||
"en": "User",
|
||||
"zh": "用户"
|
||||
"en": "User Accounts",
|
||||
"zh": "用户账号"
|
||||
},
|
||||
"userOauth2Settings": {
|
||||
"en": "Oauth2 Settings",
|
||||
"zh": "Oauth2 设置"
|
||||
},
|
||||
"user_management": {
|
||||
"en": "User Management",
|
||||
"zh": "用户管理"
|
||||
"en": "User Account Management",
|
||||
"zh": "用户账号管理"
|
||||
},
|
||||
"user_settings": {
|
||||
"en": "User Settings",
|
||||
"zh": "用户设置"
|
||||
"en": "User Account Settings",
|
||||
"zh": "用户账号设置"
|
||||
},
|
||||
"webhookSettings": {
|
||||
"en": "Webhook Settings",
|
||||
@@ -621,8 +743,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "收件箱"
|
||||
},
|
||||
"user_settings": {
|
||||
"en": "User Settings",
|
||||
"zh": "用户设置"
|
||||
"en": "User Account Settings",
|
||||
"zh": "用户账号设置"
|
||||
}
|
||||
},
|
||||
"views.user.UserLogin": {
|
||||
@@ -701,8 +823,8 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"views.user.UserBar": {
|
||||
"currentUser": {
|
||||
"en": "Current Login User",
|
||||
"zh": "当前登录用户"
|
||||
"en": "Current User Account",
|
||||
"zh": "当前用户账号"
|
||||
},
|
||||
"fetchUserSettingsError": {
|
||||
"en": "Login password is invalid or account not exist, it may be network connection issue, please try again later.",
|
||||
@@ -730,6 +852,10 @@ export const MESSAGE_REGISTRY = {
|
||||
"en": "Mail Count",
|
||||
"zh": "邮件数量"
|
||||
},
|
||||
"itemCount": {
|
||||
"en": "Total",
|
||||
"zh": "总数"
|
||||
},
|
||||
"name": {
|
||||
"en": "Name",
|
||||
"zh": "名称"
|
||||
@@ -751,8 +877,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "转移地址"
|
||||
},
|
||||
"transferAddressTip": {
|
||||
"en": "Transfer address to another user will remove the address from your account and transfer it to another user. Are you sure to transfer the address?",
|
||||
"zh": "转移地址到其他用户将会从你的账户中移除此地址并转移给其他用户。确定要转移地址吗?"
|
||||
"en": "Transferring this address removes it from your user account and binds it to another user account. Are you sure?",
|
||||
"zh": "转移后,此邮箱地址将从你的用户账号解绑,并绑定到另一个用户账号。确定要转移吗?"
|
||||
},
|
||||
"unbindAddress": {
|
||||
"en": "Unbind Address",
|
||||
@@ -789,12 +915,12 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "确认密码"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"en": "Delete Account",
|
||||
"zh": "删除账户"
|
||||
"en": "Delete Mailbox Address",
|
||||
"zh": "删除邮箱地址"
|
||||
},
|
||||
"deleteAccountConfirm": {
|
||||
"en": "Are you sure to delete your account and all emails for this account?",
|
||||
"zh": "确定要删除你的账户和其中的所有邮件吗?"
|
||||
"en": "Are you sure you want to delete this mailbox address and all of its emails?",
|
||||
"zh": "确定要删除当前邮箱地址及其全部邮件吗?"
|
||||
},
|
||||
"logout": {
|
||||
"en": "Logout",
|
||||
@@ -817,8 +943,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "密码不匹配"
|
||||
},
|
||||
"showAddressCredential": {
|
||||
"en": "Show Address Credential",
|
||||
"zh": "查看邮箱地址凭证"
|
||||
"en": "Credentials & Connection Methods",
|
||||
"zh": "地址凭证与连接方式"
|
||||
},
|
||||
"success": {
|
||||
"en": "Success",
|
||||
@@ -929,12 +1055,12 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "预览"
|
||||
},
|
||||
"requestAccess": {
|
||||
"en": "Request Access",
|
||||
"zh": "申请权限"
|
||||
"en": "Request Access for This Address",
|
||||
"zh": "为当前地址申请发信权限"
|
||||
},
|
||||
"requestAccessTip": {
|
||||
"en": "No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.",
|
||||
"zh": "当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。"
|
||||
"en": "Send access and balance belong to the current mailbox address, not the user account. This address has no send balance yet. Request access for it or contact the admin.",
|
||||
"zh": "发信权限和额度属于当前邮箱地址,不属于用户账号。当前地址还没有可用额度,请为该地址申请发信权限或联系管理员。"
|
||||
},
|
||||
"rich text": {
|
||||
"en": "Rich Text",
|
||||
@@ -979,8 +1105,8 @@ export const MESSAGE_REGISTRY = {
|
||||
},
|
||||
"views.index.SimpleIndex": {
|
||||
"accountSettings": {
|
||||
"en": "Account Settings",
|
||||
"zh": "账户设置"
|
||||
"en": "Mailbox Address Settings",
|
||||
"zh": "邮箱地址设置"
|
||||
},
|
||||
"addressCopied": {
|
||||
"en": "Address copied successfully",
|
||||
@@ -1071,7 +1197,7 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "邮箱地址凭证"
|
||||
},
|
||||
"addressCredentialTip": {
|
||||
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
|
||||
"en": "Copy this mailbox address credential to log in to this address.",
|
||||
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
|
||||
},
|
||||
"addressManage": {
|
||||
@@ -1083,7 +1209,7 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "地址密码"
|
||||
},
|
||||
"fetchAddressError": {
|
||||
"en": "Mail address credential is invalid or account not exist, it may be network connection issue, please try again later.",
|
||||
"en": "The mailbox address credential is invalid or the address does not exist. This may also be a network issue; please try again later.",
|
||||
"zh": "邮箱地址凭证无效或邮箱地址不存在,也可能是网络连接异常,请稍后再尝试。"
|
||||
},
|
||||
"linkWithAddressCredential": {
|
||||
@@ -1095,8 +1221,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "确定"
|
||||
},
|
||||
"userLogin": {
|
||||
"en": "User Login",
|
||||
"zh": "用户登录"
|
||||
"en": "User Account Login",
|
||||
"zh": "用户账号登录"
|
||||
}
|
||||
},
|
||||
"views.admin.SendBox": {
|
||||
@@ -1227,7 +1353,7 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "邮箱地址凭证"
|
||||
},
|
||||
"addressCredentialTip": {
|
||||
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
|
||||
"en": "Copy this mailbox address credential to log in to this address.",
|
||||
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
|
||||
},
|
||||
"addressQueryTip": {
|
||||
@@ -1259,8 +1385,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "删除"
|
||||
},
|
||||
"deleteAccount": {
|
||||
"en": "Delete Account",
|
||||
"zh": "删除邮箱"
|
||||
"en": "Delete Mailbox Address",
|
||||
"zh": "删除邮箱地址"
|
||||
},
|
||||
"deleteTip": {
|
||||
"en": "Are you sure to delete this email?",
|
||||
@@ -1731,8 +1857,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "更改角色"
|
||||
},
|
||||
"createUser": {
|
||||
"en": "Create User",
|
||||
"zh": "创建用户"
|
||||
"en": "Create User Account",
|
||||
"zh": "创建用户账号"
|
||||
},
|
||||
"created_at": {
|
||||
"en": "Created At",
|
||||
@@ -1743,12 +1869,12 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "删除"
|
||||
},
|
||||
"deleteUser": {
|
||||
"en": "Delete User",
|
||||
"zh": "删除用户"
|
||||
"en": "Delete User Account",
|
||||
"zh": "删除用户账号"
|
||||
},
|
||||
"deleteUserTip": {
|
||||
"en": "Are you sure you want to delete this user?",
|
||||
"zh": "确定要删除此用户吗?"
|
||||
"en": "Are you sure you want to delete this user account?",
|
||||
"zh": "确定要删除此用户账号吗?"
|
||||
},
|
||||
"domains": {
|
||||
"en": "Domains",
|
||||
@@ -1795,12 +1921,12 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "成功"
|
||||
},
|
||||
"userAddressManagement": {
|
||||
"en": "Address Management",
|
||||
"zh": "地址管理"
|
||||
"en": "Bound Address Management",
|
||||
"zh": "绑定地址管理"
|
||||
},
|
||||
"user_email": {
|
||||
"en": "User Email",
|
||||
"zh": "用户邮箱"
|
||||
"en": "User Account Email",
|
||||
"zh": "用户账号邮箱"
|
||||
}
|
||||
},
|
||||
"views.admin.Telegram": {
|
||||
@@ -1863,7 +1989,7 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "邮箱地址凭证"
|
||||
},
|
||||
"addressCredentialTip": {
|
||||
"en": "Please copy the Mail Address Credential and you can use it to login to your email account.",
|
||||
"en": "Copy this mailbox address credential to log in to this address.",
|
||||
"zh": "请复制邮箱地址凭证,你可以使用它登录你的邮箱。"
|
||||
},
|
||||
"addressPassword": {
|
||||
@@ -1891,8 +2017,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "打开即可自动登录邮箱的链接"
|
||||
},
|
||||
"randomSubdomainTip": {
|
||||
"en": "When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.",
|
||||
"zh": "启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。"
|
||||
"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 记录,详见随机子域名文档。"
|
||||
},
|
||||
"successTip": {
|
||||
"en": "Success Created",
|
||||
@@ -1980,6 +2106,10 @@ export const MESSAGE_REGISTRY = {
|
||||
}
|
||||
},
|
||||
"views.common.Appearance": {
|
||||
"autoLoadRemoteImages": {
|
||||
"en": "Automatically load external images in mail body",
|
||||
"zh": "自动加载邮件正文中的外部图片"
|
||||
},
|
||||
"autoRefreshInterval": {
|
||||
"en": "Auto Refresh Interval(Sec)",
|
||||
"zh": "自动刷新间隔(秒)"
|
||||
@@ -1997,8 +2127,20 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "左侧"
|
||||
},
|
||||
"mailboxSplitSize": {
|
||||
"en": "Mailbox Split Size",
|
||||
"zh": "邮箱界面分栏大小"
|
||||
"en": "Left list width in two-column mailbox view",
|
||||
"zh": "邮箱双栏视图左侧列表宽度占比"
|
||||
},
|
||||
"mailListView": {
|
||||
"en": "Full-width mailbox list view",
|
||||
"zh": "邮箱全宽列表视图"
|
||||
},
|
||||
"mailListPreviewLineClamp": {
|
||||
"en": "Body Preview Lines",
|
||||
"zh": "正文预览行数"
|
||||
},
|
||||
"off": {
|
||||
"en": "Off",
|
||||
"zh": "关闭"
|
||||
},
|
||||
"preferShowTextMail": {
|
||||
"en": "Display text Mail by default",
|
||||
@@ -2323,8 +2465,8 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "请输入天数"
|
||||
},
|
||||
"unboundAddressLabel": {
|
||||
"en": "Cleanup the unbound address before n days",
|
||||
"zh": "清理 n 天前的未绑定用户地址"
|
||||
"en": "Clean up mailbox addresses unbound for n days",
|
||||
"zh": "清理已解绑 n 天的邮箱地址"
|
||||
}
|
||||
},
|
||||
"views.common.Login": {
|
||||
@@ -2413,14 +2555,14 @@ export const MESSAGE_REGISTRY = {
|
||||
"zh": "请\"登录\"或点击 \"注册新邮箱\" 按钮来获取一个新的邮箱地址"
|
||||
},
|
||||
"randomSubdomainTip": {
|
||||
"en": "When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.",
|
||||
"zh": "启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。"
|
||||
"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 记录,详见随机子域名文档。"
|
||||
}
|
||||
},
|
||||
"views.admin.Webhook": {
|
||||
"enableAllowList": {
|
||||
"en": "Enable Allow List (Restrict webhook access to specific users)",
|
||||
"zh": "启用白名单 (限制 webhook 访问权限,只有白名单中的用户可以使用)"
|
||||
"en": "Enable Allow List (Restrict webhook access to specific mailbox addresses)",
|
||||
"zh": "启用白名单(仅允许白名单中的邮箱地址使用 Webhook)"
|
||||
},
|
||||
"manualInputPrompt": {
|
||||
"en": "Type and press Enter to add",
|
||||
|
||||
@@ -61,8 +61,20 @@ router.beforeEach((to, from, next) => {
|
||||
preferredLocale.value = getPreferredLocale('', getBrowserLocales())
|
||||
}
|
||||
|
||||
if (to.query.jwt) {
|
||||
jwt.value = to.query.jwt
|
||||
if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
|
||||
const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
|
||||
if (typeof jwtQuery === 'string') {
|
||||
jwt.value = jwtQuery
|
||||
}
|
||||
const query = { ...to.query }
|
||||
delete query.jwt
|
||||
next({
|
||||
path: to.path,
|
||||
query,
|
||||
hash: to.hash,
|
||||
replace: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (routeLocale) {
|
||||
|
||||
@@ -38,8 +38,22 @@ export const useGlobalState = createGlobalState(
|
||||
isS3Enabled: false,
|
||||
enableSendMail: false,
|
||||
showGithub: true,
|
||||
showGithubForUser: true,
|
||||
disableAdminPasswordCheck: false,
|
||||
enableAddressPassword: false,
|
||||
enableAgentEmailInfo: false,
|
||||
smtpImapProxyConfig: {
|
||||
smtp: {
|
||||
host: '',
|
||||
port: 8025,
|
||||
starttls: false,
|
||||
},
|
||||
imap: {
|
||||
host: '',
|
||||
port: 11143,
|
||||
starttls: false,
|
||||
},
|
||||
},
|
||||
statusUrl: '',
|
||||
enableGlobalTurnstileCheck: false,
|
||||
})
|
||||
@@ -74,6 +88,8 @@ export const useGlobalState = createGlobalState(
|
||||
const adminMailTabAddress = ref("");
|
||||
const adminSendBoxTabAddress = ref("");
|
||||
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
|
||||
const mailListView = useStorage('mailListView', false);
|
||||
const mailListPreviewLineClamp = useStorage('mailListPreviewLineClamp', 2);
|
||||
const useIframeShowMail = useStorage('useIframeShowMail', false);
|
||||
const preferShowTextMail = useStorage('preferShowTextMail', false);
|
||||
const userJwt = useStorage('userJwt', '');
|
||||
@@ -83,6 +99,7 @@ export const useGlobalState = createGlobalState(
|
||||
const globalTabplacement = useStorage('globalTabplacement', 'top');
|
||||
const useSideMargin = useStorage('useSideMargin', true);
|
||||
const useUTCDate = useStorage('useUTCDate', false);
|
||||
const autoLoadRemoteImages = useStorage('autoLoadRemoteImages', true);
|
||||
const autoRefresh = useStorage('autoRefresh', false);
|
||||
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
|
||||
const userOpenSettings = ref({
|
||||
@@ -146,6 +163,8 @@ export const useGlobalState = createGlobalState(
|
||||
adminMailTabAddress,
|
||||
adminSendBoxTabAddress,
|
||||
mailboxSplitSize,
|
||||
mailListView,
|
||||
mailListPreviewLineClamp,
|
||||
useIframeShowMail,
|
||||
preferShowTextMail,
|
||||
userJwt,
|
||||
@@ -157,6 +176,7 @@ export const useGlobalState = createGlobalState(
|
||||
globalTabplacement,
|
||||
useSideMargin,
|
||||
useUTCDate,
|
||||
autoLoadRemoteImages,
|
||||
autoRefresh,
|
||||
configAutoRefreshInterval,
|
||||
telegramApp,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { blockRemoteContent } from '../remote-content-policy';
|
||||
|
||||
const T = 'https://tracker.example/p.png';
|
||||
|
||||
function leaks(html) {
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
const f = [];
|
||||
for (const el of host.querySelectorAll('*')) {
|
||||
for (const a of el.attributes) {
|
||||
if (a.name.startsWith('data-blocked-')) continue;
|
||||
if (/tracker\.example/i.test(a.value)) f.push(`${el.tagName}[${a.name}]`);
|
||||
}
|
||||
if (el.tagName === 'STYLE' && /tracker\.example/i.test(el.textContent || '')) f.push('STYLE-text');
|
||||
}
|
||||
if (/tracker\.example/i.test(host.innerHTML) && !f.length) f.push('RAW');
|
||||
return f;
|
||||
}
|
||||
|
||||
const TAB = String.fromCharCode(9);
|
||||
const NUL = String.fromCharCode(1);
|
||||
|
||||
const V = [
|
||||
['noscript 突破', `<p>hi</p><noscript><b title="</noscript><img src=${T}>"></noscript>`],
|
||||
['base href', `<base href="https://tracker.example/"><img src="/logo.png">`],
|
||||
['iframe srcdoc', `<iframe srcdoc="<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 : '');
|
||||
};
|
||||
@@ -110,7 +110,7 @@ onMounted(async () => {
|
||||
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
|
||||
preset="dialog" :title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
|
||||
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" @keyup.enter="authFunc" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button @click="authFunc" type="primary" :loading="loading">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { useGlobalState } from '../store'
|
||||
import DOMPurify from 'dompurify'
|
||||
const { openSettings } = useGlobalState()
|
||||
|
||||
|
||||
@@ -17,7 +18,7 @@ const { t } = useScopedI18n('views.Footer')
|
||||
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }}
|
||||
</n-text>
|
||||
<n-text depth="3">
|
||||
<div v-html="openSettings.copyright"></div>
|
||||
<div v-html="DOMPurify.sanitize(openSettings.copyright)"></div>
|
||||
</n-text>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +103,11 @@ const changeLocale = async (lang) => {
|
||||
}
|
||||
|
||||
const version = import.meta.env.PACKAGE_VERSION ? `v${import.meta.env.PACKAGE_VERSION}` : "";
|
||||
const showGithubForCurrentUser = computed(() => {
|
||||
if (!openSettings.value.showGithub) return false;
|
||||
if (openSettings.value.showGithubForUser) return true;
|
||||
return showAdminPage.value;
|
||||
});
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
@@ -270,7 +275,7 @@ onMounted(async () => {
|
||||
</n-button>
|
||||
</n-dropdown>
|
||||
<n-button
|
||||
v-if="!isMobile && openSettings.showGithub"
|
||||
v-if="!isMobile && showGithubForCurrentUser"
|
||||
text
|
||||
size="small"
|
||||
class="header-version-button"
|
||||
@@ -298,7 +303,7 @@ onMounted(async () => {
|
||||
</button>
|
||||
</n-dropdown>
|
||||
<a
|
||||
v-if="openSettings.showGithub"
|
||||
v-if="showGithubForCurrentUser"
|
||||
class="mobile-menu-utility-button"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -314,7 +319,7 @@ onMounted(async () => {
|
||||
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
|
||||
:title="t('accessHeader')">
|
||||
<p>{{ t('accessTip') }}</p>
|
||||
<n-input v-model:value="auth" type="password" show-password-on="click" />
|
||||
<n-input v-model:value="auth" type="password" show-password-on="click" @keyup.enter="authFunc" />
|
||||
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="authFunc" type="primary">
|
||||
@@ -430,6 +435,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:deep(.n-page-header) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
:deep(.n-page-header__title) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useScopedI18n } from '@/i18n/app'
|
||||
|
||||
import { useGlobalState } from '../../store'
|
||||
import { api } from '../../api'
|
||||
import { hashPassword } from '../../utils'
|
||||
import { NButton, NMenu } from 'naive-ui';
|
||||
import { MenuFilled } from '@vicons/material'
|
||||
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||
|
||||
const {
|
||||
loading, adminTab, openSettings,
|
||||
@@ -18,6 +20,7 @@ const { t } = useScopedI18n('views.admin.Account')
|
||||
|
||||
const showEmailCredential = ref(false)
|
||||
const curEmailCredential = ref("")
|
||||
const curEmailAddress = ref("")
|
||||
const curDeleteAddressId = ref(0);
|
||||
const curClearInboxAddressId = ref(0);
|
||||
const curClearSentItemsAddressId = ref(0);
|
||||
@@ -46,14 +49,16 @@ const showDeleteAccount = ref(false)
|
||||
const showClearInbox = ref(false)
|
||||
const showClearSentItems = ref(false)
|
||||
|
||||
const showCredential = async (id) => {
|
||||
const showCredential = async (row) => {
|
||||
try {
|
||||
curEmailCredential.value = await api.adminShowAddressCredential(id)
|
||||
curEmailAddress.value = row.name
|
||||
curEmailCredential.value = await api.adminShowAddressCredential(row.id)
|
||||
showEmailCredential.value = true
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
showEmailCredential.value = false
|
||||
curEmailCredential.value = ""
|
||||
curEmailAddress.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +103,16 @@ const clearSentItems = async () => {
|
||||
}
|
||||
|
||||
const resetPassword = async () => {
|
||||
const normalizedPassword = newPassword.value.trim()
|
||||
if (!normalizedPassword) {
|
||||
message.error(t("newPassword"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
password: newPassword.value
|
||||
password: await hashPassword(normalizedPassword)
|
||||
})
|
||||
});
|
||||
message.success(t("passwordResetSuccess"));
|
||||
@@ -365,7 +375,7 @@ const columns = computed(() => [
|
||||
label: () => h(NButton,
|
||||
{
|
||||
text: true,
|
||||
onClick: () => showCredential(row.id)
|
||||
onClick: () => showCredential(row)
|
||||
},
|
||||
{ default: () => t('showCredential') }
|
||||
),
|
||||
@@ -467,19 +477,8 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div style="margin-top: 10px;">
|
||||
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
|
||||
<template #header>
|
||||
<div>{{ t("addressCredential") }}</div>
|
||||
</template>
|
||||
<span>
|
||||
<p>{{ t("addressCredentialTip") }}</p>
|
||||
</span>
|
||||
<n-card :bordered="false" embedded>
|
||||
<b>{{ curEmailCredential }}</b>
|
||||
</n-card>
|
||||
<template #action>
|
||||
</template>
|
||||
</n-modal>
|
||||
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
|
||||
:jwt="curEmailCredential" />
|
||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
||||
<p>{{ t('deleteTip') }}</p>
|
||||
<template #action>
|
||||
@@ -507,7 +506,8 @@ onMounted(async () => {
|
||||
|
||||
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
|
||||
<n-form-item :label="t('newPassword')">
|
||||
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
|
||||
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click"
|
||||
@keyup.enter="resetPassword" />
|
||||
</n-form-item>
|
||||
<template #action>
|
||||
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user