Compare commits

...

53 Commits

Author SHA1 Message Date
dreamhunter2333
1572f4cd0f feat: upgrade version to v1.11.1 2026-08-22 16:18:52 +08:00
Dream Hunter
aeb2ac687d fix: 修复 Admin 二级标签页状态丢失 (#1118)
fix: reset admin nested tabs on section switch
2026-08-20 02:20:40 +08:00
Dream Hunter
108b8ef4ac feat: show D1 storage capacity in admin (#1117)
* feat: show D1 storage usage in admin

* test: add D1 storage E2E coverage

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

* fix: narrow account and address terminology changes

* fix: simplify mailbox settings label

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

* test: stabilize custom subdomain browser flow

* test: fix normalized address expectation

* fix: gate custom subdomain input by setting

* fix: scope custom subdomains to random domains

* test: recreate random subdomain manually

* refactor: minimize custom subdomain changes

* refactor: reduce custom subdomain changes

* test: fix custom subdomain locator

* refactor: clarify manual subdomain validation

* test: target visible custom subdomain input

* refactor: simplify manual subdomain validation

* refactor: use subdomain mode selector

* style: stack subdomain modes vertically

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

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

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

* docs: document user address pagination

* fix: address pagination review feedback

* fix: cover user address pagination flows

* test: fix user mailbox tab selector

* test: stabilize remote address search flow

* test: stabilize user address browser flow

* fix: preserve address pagination compatibility

* refactor: simplify bound address query types

* refactor: reuse list query for bound addresses

* fix: preserve paginated address totals

* fix: preserve bound address helper contracts

* fix: require pagination for user addresses

* refactor: keep shared pagination behavior unchanged

* fix: align pagination docs and tests

* fix: clear stale address selections

* refactor: simplify user address pagination

* refactor: limit user address changes to pagination

* test: select a visible mailbox address

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

* docs: record address activity write throttling

* refactor: inline address activity interval

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

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

* fix: preserve safe escaped CSS

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

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

Closes #1073

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

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

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

Two changes rather than a wider regex:

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

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

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

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

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

Refs #1073

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

---------

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

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

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

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

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

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

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

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

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

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

* chore: clarify some i18n message in settings

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

* docs: update changelog with recent UI improvements

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

* docs: fix CHANGELOG improvements types

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

* fix: validate extracted links against full email content

* refactor: simplify AI link domain guard

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

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

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

* fix: limit page header padding to mobile layout

* docs: update changelog for mobile header fix

---------

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

- Add v1.10.0 placeholder in CHANGELOG.md
2026-06-03 22:14:44 +08:00
Dream Hunter
05a8ebb590 chore: upgrade dependencies (#1050) 2026-06-02 20:39:36 +08:00
Gene Dai
b7718100c5 feat: regex fallback for verification code extraction without Workers AI (#1048)
feat: add regex fallback for verification code extraction without Workers AI

When AI email extraction is enabled but no Workers AI binding is available,
fall back to a built-in, zero-dependency regex extractor so self-hosted
deployments without Workers AI still surface verification codes in Telegram
notifications and webhooks.

- Add worker/src/email/extract_code.ts: rule-based multilingual
  (English / Chinese / Japanese / Korean) verification-code extractor with
  year and YYYYMMDD date rejection to avoid false positives.
- ai_extract.ts: share the allowlist check and content parsing across both
  paths, extract a saveExtractMetadata helper, and use the regex fallback
  when env.AI is absent.
- Reuse the existing aiExtractResult pipeline (auth_code type), so Telegram
  and webhook output need no changes.
- Update bilingual CHANGELOG and AI-extract feature docs.
2026-06-02 15:35:43 +08:00
Dream Hunter
bf786947e3 feat: add AI extract webhook placeholders 2026-05-29 02:27:46 +08:00
Dream Hunter
cfb31807f1 fix: keep Telegram AI extract result on metadata errors (#1045) 2026-05-29 01:48:02 +08:00
Wolf-L
308fbe2f9a feat: show AI extraction results in Telegram
Show AI extraction results in Telegram notifications and /mails views.
2026-05-29 01:13:31 +08:00
Dream Hunter
44b29aa646 feat: hide GitHub links for normal users
Add DISABLE_SHOW_GITHUB_FOR_USER to hide the Header GitHub/version entry from normal users while keeping it visible to admin users. Refs #1041
2026-05-21 23:38:49 +08:00
tuanaiseo
2221342560 fix: sanitize footer copyright html
Sanitize footer copyright HTML before rendering it with v-html.
2026-05-17 15:56:06 +08:00
Hging
add0124cfd fix: normalize domain casing
Fix domain casing normalization for configured domains and inbound recipient domains.
2026-05-16 18:35:39 +08:00
Dream Hunter
8324b133fb docs: clarify wildcard MX requirement for random subdomain (#1036)
Random subdomain mailbox creation only generates addresses; mail delivery
depends on DNS / Cloudflare Email Routing covering *.<base-domain>.
Cloudflare Email Routing does not inherit apex configuration onto
subdomains, so a wildcard `*` MX record on the base domain is required
for random subdomains to actually receive mail.

- Add `[!IMPORTANT]` block in subdomain.md (zh/en) explaining the two
  deliverable paths: DNS-only wildcard MX (recommended for random
  subdomains) vs Cloudflare dashboard "Add subdomain"
- Link to Cloudflare Email Routing — Subdomains official docs from
  worker-vars.md and subdomain.md
- Instruct copying apex MX records to host `*` preserving each record's
  priority/target, instead of hardcoding specific MX targets
- Shorten frontend `randomSubdomainTip` for CreateAccount and Login
  views (6 locales: zh/en/de/es/ja/pt-BR), drop Markdown backticks
  (Vue text interpolation renders them literally), and point users to
  the docs instead of embedding DNS instructions
- Trim overlap between existing `[!NOTE]` and new `[!IMPORTANT]` in
  subdomain.md
- Update CHANGELOG.md / CHANGELOG_EN.md under v1.9.0(main)

Closes #1035
Closes #1026

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:09:32 +08:00
Dream Hunter
74c8e8f7e4 fix: prevent iOS input focus zoom (#1033)
fix: prevent ios input focus zoom
2026-05-10 02:05:31 +08:00
Dream Hunter
2df4de22d9 chore: update SimpleWebAuthn dependencies (#1032)
chore: update simplewebauthn dependencies
2026-05-10 01:54:51 +08:00
Dream Hunter
437db7c050 fix: update AI extract default model
* fix: update AI extract default model

* fix: update e2e worker node version

* fix: use node lts for e2e worker

* fix: align AI model and CI node version
2026-05-10 01:18:23 +08:00
Dream Hunter
dd294037ab chore: upgrade project dependencies 2026-05-10 00:55:16 +08:00
Charlson
a9e2c89246 ci: allow docs deploy without GitHub release
* ci: allow docs deploy without GitHub release

* fix: use workflow run branch for docs tag fallback

---------

Co-authored-by: dreamhunter2333 <dreamhunter2333@gmail.com>
2026-05-10 00:50:08 +08:00
tar-xz
72bbfe8fd6 docs: fix GitHub Actions title typo
Fix a typo in the Chinese GitHub Actions deployment prerequisite title.
2026-05-01 00:20:17 +08:00
Dream Hunter
796a5e4ac5 feat: improve address credential connections 2026-04-30 15:33:06 +08:00
Dream Hunter
347be5c762 chore: prepare v1.9.0
- bump project version metadata to v1.9.0
- refresh npm dependencies and lockfiles across frontend, worker, pages, and docs
- link .agents/skills to .claude/skills
2026-04-30 02:03:51 +08:00
152 changed files with 9593 additions and 4807 deletions

1
.agents/skills Symbolic link
View File

@@ -0,0 +1 @@
../.claude/skills

View File

@@ -1 +0,0 @@
../../skills/cf-temp-mail-agent-mail

View File

@@ -1 +0,0 @@
../../.claude/skills/cf-temp-mail-upgrade-dependencies

View File

@@ -1 +0,0 @@
../../.claude/skills/cf-temp-mail-version-upgrade

View File

@@ -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

View File

@@ -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}
```

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

3
.gitignore vendored
View File

@@ -142,3 +142,6 @@ pnpm-lock.yaml
e2e/test-results/
e2e/playwright-report/
e2e/.e2e-pids
# SMTP/IMAP proxy persisted IMAP flags (local dev / bare-metal runs)
smtp_proxy_server/data/

View File

@@ -6,7 +6,101 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.8.0(main)
## v1.11.1(main)
### Features
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
### Bug Fixes
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
### Improvements
### Testing
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
## v1.11.0
### Features
- feat: |Frontend| 在随机子域名允许范围内新增普通、随机和自定义子域名模式选择issue #1108
### Bug Fixes
- fix: |Frontend| 将邮箱主页的账户设置、删除账户等表述更正为邮箱地址操作,并明确发信权限与额度按当前邮箱地址独立管理
### Improvements
- docs: |发送邮件| 补充用户账号、邮箱地址和发信权限的概念区别及按当前地址申请权限的操作说明
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量issue #1103
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属避免为大用户加载全部绑定地址issue #1103
- feat: |Worker| 邮件、发件箱及按创建/活跃时间清理地址时改为分批处理,默认每次最多 3000 条并支持通过 `CLEANUP_BATCH_SIZE` 调整(上限 5000减少单次扫描和删除量issue #1103
### Testing
- fix: |E2E| 新增近期地址活跃时间不会被用户设置接口重复写入的回归测试
- fix: |E2E| 新增清理批次上限、后续批次继续执行、保留未过期数据及地址关联数据清理测试
## v1.10.0
### Features
- feat: |Admin| 新增 `GET /admin/mails/:id` 接口,支持管理员按邮件 ID 跨邮箱读取单封邮件,并兼容 gzip 压缩存储issue #1096
- feat: |Frontend| 邮箱新增「邮箱全宽列表视图」开关(在外观设置中控制),开启后默认全宽列表展示邮件标题与正文预览,点击单封邮件再展开为双栏,再次点击同一封邮件可回到列表视图;多选模式下点击邮件会同步切换勾选状态与右侧预览,并禁用同邮件点击收回列表,展开时双栏左侧列表宽度仍遵循「邮箱双栏视图左侧列表宽度占比」配置;默认关闭,保留原有双栏行为
- feat: |Frontend| 邮箱全宽列表视图新增「正文预览行数」配置(在外观设置中控制),可设置邮件正文预览的最大行数,默认 2 行0 表示关闭预览
- feat: |Frontend| 外观设置新增「自动加载邮件正文中的外部图片」开关,关闭后邮件预览(含全屏视图)会先经 DOMPurify 消毒,并以白名单策略处理所有可能发起请求的位置:仅保留可证明为本地的引用(`cid:``data:image/``blob:` 与站内相对路径),其余一律阻断;`base``meta``script``link``iframe``object``embed``noscript` 等会自行取用资源或改变解析基准的元素在此模式下移除,`<style>` 保留但其中 `url()``image-set()``@import` 的远端引用会被替换。正文上方显示已阻断资源数量的提示条可一键按封加载默认保持开启行为与此前一致issue #1073
### Bug Fixes
- fix: |Frontend| 关闭邮件外部图片自动加载时保留 `<a>``<area>` 的外部导航链接,并阻断通过 CSS 转义函数名或 at-rule 绕过远程资源过滤的情况
- fix: |Frontend| 使用共享 DOMPurify 净化逻辑处理关于页面与启动通知中的 HTML 公告,避免 `ANNOUNCEMENT` 中的可执行标签或事件属性造成 XSS
- fix: |Worker| 按邮件认证规范修复垃圾邮件检测SPF、DKIM、DMARC 的 `none` 及 SPF/DKIM `neutral` 按认证方法不存在处理,并忽略未注册结果和不支持的方法版本;`JUNK_MAIL_FORCE_PASS_LIST` 仍要求明确返回受支持的 `pass`
- fix: |Admin| 管理后台删除邮箱地址时,先删除该地址的邮件、发件记录、自动回复等关联数据,最后再删除地址本身;此前地址行先被删除导致按地址名匹配的子查询查不到数据,邮件等记录被遗留在数据库中
- fix: |AI 提取| 强化提示词,要求 AI 保持邮件原始链接域名避免小模型改写验证链接域名导致错误跳转issue #1072
- fix: |AI 提取| HTML-only 邮件在发送给 Workers AI 前会先压缩为可读文本,避免样式模板过长导致验证码位于 4000 字截断之后而无法识别
- fix: |Frontend| 移动端 Header 增加页头内边距,避免标题、菜单按钮与屏幕边缘过近
- fix: |IMAP 代理| 修复 IMAP `STORE` 无法真正标记邮件已读的问题:邮件不再硬编码为 `\Seen`,且 `SimpleMailbox` 的 flags 变更现持久化到本地 SQLite新增 `imap_flag_db_path` 配置),使已读/未读状态可在客户端断线重连(如 Thunderbird 轮询后保留而非每次新建连接即丢失issue #1074
- fix: |IMAP 代理| 修复 `SEARCH UNSEEN` 返回全部邮件的问题:`SimpleMailbox.search()` 现按持久化的 flags 计算 `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` 及其否定形式,多个条件按 AND 组合;无法识别的检索条件仍沿用原有行为返回全部邮件
- fix: |IMAP 代理| 修复取信不会自动标记已读的问题:`BODY[...]``RFC822``RFC822.TEXT` 取信现按 RFC 3501 自动置 `\Seen`,而 `BODY.PEEK[...]``RFC822.HEADER` 及仅取元数据(如 `FLAGS`)不会
### Testing
- test: |Worker| 新增 junk_mail_policy 回归测试issue #1084):覆盖 SPF/DKIM/DMARC 的 `none`/`neutral` 按认证方法不存在处理、明确 `fail` 仍被拒收,以及 `JUNK_MAIL_FORCE_PASS_LIST` 仅接受明确 `pass`
### Improvements
- docs: |README| 新增完整日文 README并在中文和英文 README 中添加日文导航链接
- feat: |Frontend| 「邮箱双栏视图左侧列表宽度占比」最小值由 0.25 放宽至 0左侧列表可完全折叠使正文近乎全屏刻度增加 0 点;收件箱与发件箱的双栏拆分同步生效,并优化外观设置文案以明确该比例控制左侧邮件列表宽度
## v1.9.0
### Features
- 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

View File

@@ -6,7 +6,101 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.8.0(main)
## v1.11.1(main)
### Features
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
### Bug Fixes
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
### Improvements
### Testing
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
## v1.11.0
### Features
- feat: |Frontend| Add Normal, Random, and Custom subdomain mode selection within the random-subdomain scope (issue #1108)
### Bug Fixes
- fix: |Frontend| Relabel mailbox settings and deletion actions as email-address operations, and clarify that send permission and balance are managed independently for the current email address
### Improvements
- docs: |Send Mail| Document the differences between user accounts, email addresses, and send permission, including how to request permission for the currently selected address
- fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103)
- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103)
- feat: |Worker| Process mail, sent-mail, and creation/activity-based address cleanup in batches of 3000 by default, configurable through `CLEANUP_BATCH_SIZE` up to 5000, reducing per-run scans and deletes (issue #1103)
### Testing
- fix: |E2E| Add regression coverage ensuring user settings do not rewrite recent address activity timestamps
- fix: |E2E| Cover cleanup batch limits, continuation on later runs, preservation of recent data, and address-related data cleanup
## v1.10.0
### Features
- feat: |Admin| Add `GET /admin/mails/:id` for administrators to fetch a single mail by ID across mailboxes, including gzip-compressed storage support (issue #1096)
- feat: |Frontend| Add a "Full-width mailbox list view" toggle in Appearance settings. When enabled, the mailbox shows a full-width list of subjects and body previews by default; clicking a mail expands it into the two-pane split view, clicking the same mail again returns to the list view; in multi-select mode, clicking a mail updates both its checked state and the right-side preview while disabling same-mail collapse, and the split width still follows the "Left list width in two-column mailbox view" setting. Defaults to off, preserving the original two-pane behavior
- feat: |Frontend| Add "Body Preview Lines" in Appearance settings for the full-width mailbox list view, allowing runtime control over the body-preview clamp. It defaults to 2 lines, and 0 disables previews
- feat: |Frontend| Add an "Automatically load external images in mail body" toggle in Appearance settings. When disabled, the mail body (including fullscreen view) is run through DOMPurify and an allowlist policy: only references that can be *proven* local are kept (`cid:`, `data:image/`, `blob:` and same-origin relative paths), everything else is blocked. Elements that fetch on their own or change how relative URLs resolve — `base`, `meta`, `script`, `link`, `iframe`, `object`, `embed`, `noscript` — are removed in this mode, while `<style>` is kept with remote `url()`, `image-set()` and `@import` references substituted. A banner above the body reports how many resources were blocked and loads them for that mail on demand; defaults to on, preserving the previous behavior (issue #1073)
### Bug Fixes
- fix: |Frontend| Preserve external navigation links on `<a>` and `<area>` elements when automatic remote-image loading is disabled, and block remote CSS resources hidden behind escaped function or at-rule names
- fix: |Frontend| Sanitize HTML announcements in both the About page and startup notification through a shared DOMPurify helper, preventing executable tags or event attributes in `ANNOUNCEMENT` from causing XSS
- fix: |Worker| Align junk-mail checking with authentication standards: treat SPF, DKIM, and DMARC `none` plus SPF/DKIM `neutral` as absent, and ignore unregistered results and unsupported method versions; `JUNK_MAIL_FORCE_PASS_LIST` still requires an explicit supported `pass`
- fix: |Admin| When deleting an address from the admin panel, delete its mails, sender records, sendbox and auto-reply entries before removing the address row itself; previously the address row was deleted first, so the name-based subqueries matched nothing and the mails were left orphaned in the database
- fix: |AI Extract| Strengthen the prompt to keep original link domains from the email, preventing small models from rewriting verification-link domains (issue #1072)
- fix: |AI Extract| Convert HTML-only mail bodies into compact readable text before sending them to Workers AI, preventing long templates from pushing verification codes past the 4000-character truncation window
- fix: |Frontend| Add mobile Header page padding so the title and menu button no longer sit too close to the screen edge
- fix: |IMAP Proxy| Fix IMAP `STORE` not actually marking mail as read: messages are no longer hardcoded to `\Seen`, and `SimpleMailbox` flag changes are now persisted to a local SQLite file (new `imap_flag_db_path` setting) so the read/unread state survives a client disconnect and reconnect (e.g. Thunderbird polling) instead of resetting on every new connection (issue #1074)
- fix: |IMAP Proxy| Fix `SEARCH UNSEEN` returning every message: `SimpleMailbox.search()` now evaluates `SEEN`/`UNSEEN`/`FLAGGED`/`DELETED`/`ANSWERED`/`DRAFT` and their negations against the persisted flags, combining multiple keys with AND; search keys it cannot evaluate keep the previous behaviour of matching everything
- fix: |IMAP Proxy| Fix fetches never marking mail as read: `BODY[...]`, `RFC822` and `RFC822.TEXT` fetches now set `\Seen` per RFC 3501, while `BODY.PEEK[...]`, `RFC822.HEADER` and metadata-only fetches (e.g. `FLAGS`) do not
### Testing
- test: |Worker| Add junk_mail_policy regression tests (issue #1084): `none`/`neutral` results for SPF/DKIM/DMARC are treated as the method being absent, explicit `fail` results are still rejected, and `JUNK_MAIL_FORCE_PASS_LIST` only accepts an explicit `pass`
### Improvements
- docs: |README| Add a complete Japanese README and Japanese navigation links to the Chinese and English READMEs
- feat: |Frontend| Lower the "Left list width in two-column mailbox view" minimum from 0.25 to 0 so the left list pane can fully collapse for a near-fullscreen content view, with a 0 mark added; applies to both the inbox and send-box two-pane splits, and clarifies the Appearance setting label so it is clear the value controls the left mail list width
## v1.9.0
### Features
- 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

View File

@@ -30,7 +30,8 @@
<p align="center">
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
<a href="README_EN.md">English Document</a> |
<a href="README_JA.md">日本語ドキュメント</a>
</p>
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
@@ -74,9 +75,9 @@
<summary>Star History点击收缩/展开)</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>
</details>

View File

@@ -30,7 +30,8 @@
<p align="center">
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
<a href="README_EN.md">English Document</a> |
<a href="README_JA.md">日本語ドキュメント</a>
</p>
> This project is for learning and personal use only. Please do not use it for any illegal activities, or you will be responsible for the consequences.
@@ -74,9 +75,9 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<summary>Star History (Click to expand/collapse)</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>
</details>

202
README_JA.md Normal file
View File

@@ -0,0 +1,202 @@
<!-- markdownlint-disable-file MD033 MD045 -->
# Cloudflare 一時メール - 無料で構築できる一時メールサービス
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
</a>
</p>
<p align="center">
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="FeaturedHelloGitHub" height="30"/>
</a>
</p>
<p align="center">
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a> |
<a href="README_JA.md">日本語ドキュメント</a>
</p>
> 本プロジェクトは学習および個人利用のみを目的としています。違法行為には使用しないでください。使用した場合、その結果については利用者自身が責任を負うものとします。
**機能の充実した一時メールサービスです!**
- **完全無料** - Cloudflare の無料サービス上に構築され、運用コストはかかりません
- **高性能** - Rust WASM によるメール解析で極めて高速に応答します
- **モダンな UI** - 多言語対応のレスポンシブデザインで、簡単に操作できます
- **アドレスパスワード** - メールアドレスごとに個別のパスワードを設定し、セキュリティを強化できます
- **Agent フレンドリー** - AI agent がメールボックスを利用できる組み込みの [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) を提供します
- **モバイル管理** - Android の管理画面とメールボックス管理に対応したコミュニティクライアント [CloudMail](https://github.com/Lur1N77777/CloudMail) を利用できます
## デプロイドキュメント - クイックスタート
[ドキュメント](https://temp-mail-docs.awsl.uk) | [GitHub Actions デプロイガイド](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
</a>
## 変更履歴
最新の更新内容については [CHANGELOG](CHANGELOG.md) を参照してください。
## ライブデモ
今すぐ試す → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<details>
<summary>サービス稼働状況(クリックして展開/折りたたみ)</summary>
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Backend](https://temp-email-api.awsl.uk/) | [![Deploy Backend Production](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | [![Deploy Frontend](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |
</details>
<details>
<summary>Star Historyクリックして展開折りたたみ</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>
</details>
<details open>
<summary>目次(クリックして展開/折りたたみ)</summary>
- [Cloudflare 一時メール - 無料で構築できる一時メールサービス](#cloudflare-一時メール---無料で構築できる一時メールサービス)
- [デプロイドキュメント - クイックスタート](#デプロイドキュメント---クイックスタート)
- [変更履歴](#変更履歴)
- [ライブデモ](#ライブデモ)
- [主な機能](#主な機能)
- [メール処理](#メール処理)
- [ユーザー管理](#ユーザー管理)
- [管理機能](#管理機能)
- [多言語対応とインターフェース](#多言語対応とインターフェース)
- [連携と拡張](#連携と拡張)
- [技術アーキテクチャ](#技術アーキテクチャ)
- [システム構成](#システム構成)
- [技術スタック](#技術スタック)
- [主要コンポーネント](#主要コンポーネント)
- [コミュニティに参加](#コミュニティに参加)
</details>
## 主な機能
<details open>
<summary>主な機能の詳細(クリックして展開/折りたたみ)</summary>
### メール処理
- [x] `rust wasm` でメールを高速に解析します。ほぼすべてのメールを解析でき、Node.js の解析モジュールで失敗するメールも rust wasm なら正常に解析できます
- [x] **AI メール認識** - Cloudflare Workers AI を使用して、メール内の確認コード、認証リンク、サービスリンクなどの重要情報を自動抽出します
- [x] 指定したベースドメインに対して、ランダムな第 2 レベルサブドメインのメールボックスを任意で作成できます
- [x] `DKIM` 検証付きのメール送信に対応します
- [x] `SMTP``Resend` など、複数の送信方法に対応します
- [x] 添付ファイルの表示機能を備え、添付画像も表示できます
- [x] S3 での添付ファイルの保存と削除に対応します
- [x] スパム検出とブラックリスト/ホワイトリストの設定に対応します
- [x] グローバル転送先アドレスを指定できるメール転送機能を備えています
### ユーザー管理
- [x] `credentials` を使用して、以前利用したメールボックスへ再ログインできます
- [x] 完全なユーザー登録・ログイン機能を備えています。メールアドレスを紐付けると、そのアドレスのメール JWT 資格情報を自動取得し、複数のメールボックスを切り替えられます
- [x] `OAuth2` によるサードパーティログインGithub、Authentik など)に対応します
- [x] `Passkey` によるパスワードレスログインに対応します
- [x] 複数ロールのドメインおよびプレフィックスを設定できるユーザーロール管理に対応します
- [x] アドレスやキーワードで絞り込めるユーザー受信箱を提供します
### 管理機能
- [x] 完全な admin コンソールを備えています
- [x] `admin` バックエンドからプレフィックスのないメールボックスを作成できます
- [x] admin ユーザー管理画面でユーザーのアドレスを確認できます
- [x] 複数のクリーンアップ戦略を選べる定期クリーンアップ機能を備えています
- [x] カスタム名のメールボックスを取得でき、`admin` でブラックリストを設定できます
- [x] アクセスパスワードを追加し、プライベートサイトとして利用できます
### 多言語対応とインターフェース
- [x] フロントエンドとバックエンドの両方が多言語に対応しています
- [x] レスポンシブレイアウトのモダンな UI デザインを採用しています
- [x] Google Ads の連携に対応します
- [x] shadow DOM を使用してスタイルの干渉を防ぎます
- [x] URL の JWT パラメーターによる自動ログインに対応します
### 連携と拡張
- [x] 完全な `Telegram Bot``Telegram` プッシュ通知、Telegram Bot ミニアプリに対応します
- [x] `SMTP proxy server` を追加し、`SMTP` によるメール送信と `IMAP` によるメール閲覧に対応します
- [x] Webhook とメッセージプッシュ連携に対応します
- [x] `CF Turnstile` CAPTCHA 検証に対応します
- [x] 悪用を防ぐためのレート制限を設定できます
- [x] **Agent フレンドリー**:組み込みの [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill により、AI agent がメールボックスを直接利用できます。詳しくは[ドキュメント](vitepress-docs/docs/en/guide/feature/agent-email.md)を参照してください
- [x] コミュニティのモバイル管理クライアント:[CloudMail](https://github.com/Lur1N77777/CloudMail) は本プロジェクト互換の API 向けに Expo / React Native で構築されており、Android 管理コンソール、アドレス管理、受信済み送信済み不明メール、確認コードのクイックコピー、OLED ブラックテーマ、ローカルグループ分けを提供します
</details>
## 技術アーキテクチャ
<details>
<summary>技術アーキテクチャの詳細(クリックして展開/折りたたみ)</summary>
### システム構成
- **データベース**:メインデータベースとして Cloudflare D1 を使用
- **フロントエンドのデプロイ**Cloudflare Pages を使用してフロントエンドをデプロイ
- **バックエンドのデプロイ**Cloudflare Workers を使用してバックエンドをデプロイ
- **メールルーティング**Cloudflare Email Routing を使用
### 技術スタック
- **フロントエンド**Vue 3 + Vite + TypeScript
- **バックエンド**TypeScript + Cloudflare Workers
- **メール解析**Rust WASMmail-parser-wasm
- **データベース**Cloudflare D1SQLite
- **ストレージ**Cloudflare KV + R2任意で S3
- **プロキシサービス**Python SMTP/IMAP Proxy Server
### 主要コンポーネント
- **Worker**:中核となるバックエンドサービス
- **Frontend**Vue 3 ユーザーインターフェース
- **Mail Parser WASM**Rust メール解析モジュール
- **SMTP Proxy Server**Python メールプロキシサービス
- **Pages Functions**Cloudflare Pages ミドルウェア
- **Documentation**VitePress ドキュメントサイト
</details>
### 重要な注意事項
- Resend でドメインレコードを追加する際、DNS プロバイダーが第 3 レベルドメイン a.b.com をホストしている場合は、Resend が生成したデフォルト名から第 2 レベルドメインのプレフィックス b を削除してください。削除しないと a.b.b.com が追加され、検証に失敗します。レコードを追加した後、次のコマンドで確認できます。
```bash
nslookup -qt="mx" a.b.com 1.1.1.1
```
## コミュニティに参加
- [Telegram](https://t.me/cloudflare_temp_email)

View File

@@ -1,5 +1,5 @@
# Keep this version in sync with @playwright/test in package.json
FROM mcr.microsoft.com/playwright:v1.58.2-noble
FROM mcr.microsoft.com/playwright:v1.62.1-noble
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -5,9 +5,15 @@ 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", "MANUAL.EXAMPLE.COM"]
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
USER_ROLES = [
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
{ domains = [], role = "empty-role", prefix = "EMPTY" },
]
JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
@@ -19,8 +25,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]]

View File

@@ -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]]

167
e2e/package-lock.json generated
View File

@@ -6,14 +6,14 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.7.1",
"nodemailer": "^9.0.5"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
"@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"ws": "^8.21.3"
}
},
"node_modules/@pinojs/redact": {
@@ -23,35 +23,35 @@
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -69,13 +69,13 @@
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"version": "5.4.15",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.15.tgz",
"integrity": "sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libmime": "5.4.2",
"libqp": "2.1.1"
}
},
@@ -113,9 +113,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -129,38 +129,25 @@
}
},
"node_modules/imapflow": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.1.tgz",
"integrity": "sha512-etpyRH7wOPQGawjl/5ChgaaX5NRK2fxJAi9EFFNwv0N3FXsvjsL3Qu5dJ4i/myH61Cq2RSTb6vI5T9rXDbXD+g==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"@zone-eu/mailsplit": "5.4.15",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libmime": "5.4.2",
"libqp": "2.1.1",
"nodemailer": "8.0.5",
"pino": "10.3.1",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
"socks": "2.8.9"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -173,29 +160,17 @@
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.2.tgz",
"integrity": "sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
@@ -203,9 +178,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz",
"integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -258,41 +233,41 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
"funding": [
{
"type": "github",
@@ -346,12 +321,12 @@
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
@@ -378,28 +353,34 @@
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -7,13 +7,13 @@
"test:down": "docker compose down -v"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
"@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"ws": "^8.21.3"
},
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
"imapflow": "^1.7.1",
"nodemailer": "^9.0.5"
}
}

View File

@@ -140,6 +140,34 @@ test.describe('Mail Gzip Storage', () => {
}
});
test('gzip-compressed mail is readable through admin detail API', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-detail');
try {
await receiveGzipMail(request, address, {
subject: 'Gzip Admin Detail Test',
text: 'admin compressed content',
});
const listRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const { results } = await listRes.json();
expect(results.length).toBeGreaterThanOrEqual(1);
const mailId = results[0].id;
const detailRes = await request.get(`${WORKER_GZIP_URL}/admin/mails/${mailId}`, {
headers: { 'x-admin-auth': 'e2e-admin-pass' },
});
expect(detailRes.ok()).toBe(true);
const mail = await detailRes.json();
expect(mail.raw).toContain('Gzip Admin Detail Test');
expect(mail.raw).toContain('admin compressed content');
expect(mail.raw_blob).toBeUndefined();
} finally {
await deleteGzipAddress(request, jwt);
}
});
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
try {

View File

@@ -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);
}
}
});
});

View File

@@ -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);
}
});
});

View File

@@ -0,0 +1,73 @@
import { expect, test } from '@playwright/test';
import { WORKER_URL } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test.describe('Admin Config and D1 Storage', () => {
test('reports database size in the database status response', async ({ request }) => {
const response = await request.get(`${WORKER_URL}/admin/db_version`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body.database_size).toEqual(expect.any(Number));
expect(body.database_size).toBeGreaterThan(0);
});
test('saves and retrieves a namespaced config value', async ({ request }) => {
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'd1_storage_plan', value: 'free' },
});
expect(saveResponse.ok()).toBe(true);
expect(await saveResponse.json()).toEqual({
success: true,
key: 'd1_storage_plan',
value: 'free',
});
const getResponse = await request.get(`${WORKER_URL}/admin/config/d1_storage_plan`, {
headers: ADMIN_HEADERS,
});
expect(getResponse.ok()).toBe(true);
expect(await getResponse.json()).toEqual({
key: 'd1_storage_plan',
value: 'free',
});
});
test('keeps config values isolated from internal settings', async ({ request }) => {
const versionBeforeResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
headers: ADMIN_HEADERS,
});
const versionBefore = (await versionBeforeResponse.json()).current_db_version;
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'db_version', value: 'shadow-version' },
});
expect(saveResponse.ok()).toBe(true);
const versionAfterResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
headers: ADMIN_HEADERS,
});
expect((await versionAfterResponse.json()).current_db_version).toBe(versionBefore);
});
test('rejects invalid keys and non-string values', async ({ request }) => {
const invalidKeyResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'invalid key', value: 'value' },
});
expect(invalidKeyResponse.status()).toBe(400);
const invalidValueResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'valid_key', value: 1 },
});
expect(invalidValueResponse.status()).toBe(400);
});
});

View File

@@ -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);
});
});

View File

@@ -0,0 +1,104 @@
import { test, expect, type APIRequestContext } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
const listMails = async (request: APIRequestContext, address: string) => {
const response = await request.get(`${WORKER_URL}/admin/mails`, {
params: { address, limit: '100', offset: '0' },
});
expect(response.ok()).toBe(true);
return response.json();
};
test.describe('Bounded cleanup', () => {
test('cleans at most one batch and continues on the next run', async ({ request }) => {
const address = `cleanup-batch-${Date.now()}@test.example.com`;
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: {
address,
raw: 'old cleanup mail',
message_id: `<cleanup-old-${Date.now()}-${index}@test>`,
},
})
));
expect(seedResponses.every((response) => response.ok())).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 1100));
const firstCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
data: { cleanType: 'mails', cleanDays: 0 },
});
expect(firstCleanup.ok()).toBe(true);
const afterFirstCleanup = await listMails(request, address);
expect(afterFirstCleanup.count).toBe(1);
const secondCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
data: { cleanType: 'mails', cleanDays: 0 },
});
expect(secondCleanup.ok()).toBe(true);
const afterSecondCleanup = await listMails(request, address);
expect(afterSecondCleanup.count).toBe(0);
const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: {
address,
raw: 'recent cleanup mail',
message_id: `<cleanup-recent-${Date.now()}@test>`,
},
});
expect(recentMailResponse.ok()).toBe(true);
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
data: { cleanType: 'mails', cleanDays: 1 },
});
expect(recentCleanup.ok()).toBe(true);
const recentMails = await listMails(request, address);
expect(recentMails.count).toBe(1);
expect(recentMails.results[0].raw).toBe('recent cleanup mail');
await request.delete(`${WORKER_URL}/admin/mails/${recentMails.results[0].id}`);
});
test('deletes one address batch and its related data', async ({ request }) => {
const oldAddress = await createTestAddress(request, 'cleanup-old');
const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: {
address: oldAddress.address,
raw: 'address cleanup mail',
message_id: `<cleanup-address-${Date.now()}@test>`,
},
});
expect(seedResponse.ok()).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 1100));
const cleanupResponse = await request.post(`${WORKER_URL}/admin/cleanup`, {
data: { cleanType: 'addressCreated', cleanDays: 0 },
});
expect(cleanupResponse.ok()).toBe(true);
const oldAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
params: { query: oldAddress.address, limit: '20', offset: '0' },
});
expect(oldAddressResponse.ok()).toBe(true);
expect((await oldAddressResponse.json()).count).toBe(0);
expect((await listMails(request, oldAddress.address)).count).toBe(0);
const recentAddress = await createTestAddress(request, 'cleanup-recent');
try {
const recentCleanup = await request.post(`${WORKER_URL}/admin/cleanup`, {
data: { cleanType: 'addressCreated', cleanDays: 1 },
});
expect(recentCleanup.ok()).toBe(true);
const recentAddressResponse = await request.get(`${WORKER_URL}/admin/address`, {
params: { query: recentAddress.address, limit: '20', offset: '0' },
});
expect(recentAddressResponse.ok()).toBe(true);
expect((await recentAddressResponse.json()).count).toBe(1);
} finally {
await deleteAddress(request, recentAddress.jwt);
}
});
});

View File

@@ -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);
}
});
});

View File

@@ -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);

View File

@@ -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: ''
},
});

View File

@@ -1,6 +1,8 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test.describe('Mail Detail', () => {
test('fetch a single mail by ID', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'detail-get');
@@ -53,3 +55,46 @@ test.describe('Mail Detail', () => {
}
});
});
test.describe('Admin Mail Detail', () => {
test('fetch a single mail by ID without a mailbox JWT', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'admin-detail-get');
try {
await seedTestMail(request, address, {
subject: 'Admin Detail Test',
from: 'admin-detail@test.example.com',
text: 'Hello admin detail',
});
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(listRes.ok()).toBe(true);
const { results } = await listRes.json();
expect(results).toHaveLength(1);
const mailId = results[0].id;
const detailRes = await request.get(`${WORKER_URL}/admin/mails/${mailId}`, {
headers: ADMIN_HEADERS,
});
expect(detailRes.ok()).toBe(true);
const mail = await detailRes.json();
expect(mail.id).toBe(mailId);
expect(mail.address).toBe(address);
expect(mail.source).toBe('admin-detail@test.example.com');
expect(mail.raw).toContain('Admin Detail Test');
expect(mail.raw_blob).toBeUndefined();
} finally {
await deleteAddress(request, jwt);
}
});
test('fetch non-existent mail returns null', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/admin/mails/99999999`, {
headers: ADMIN_HEADERS,
});
expect(res.ok()).toBe(true);
expect(await res.json()).toBeNull();
});
});

View File

@@ -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,

View File

@@ -1,13 +1,15 @@
import { test, expect } from '@playwright/test';
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
const MANUAL_BASE_DOMAIN = 'manual.example.com';
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_DOMAIN}`;
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${MANUAL_BASE_DOMAIN}`;
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${MANUAL_BASE_DOMAIN}`;
const RANDOM_SUBDOMAIN = `team.${TEST_DOMAIN}`;
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
let originalCreateAddressStoredEnabled: boolean | undefined;
let originalEnvOffStoredEnabled: boolean | undefined;
@@ -119,16 +121,16 @@ test.describe('Create Address Subdomain Match', () => {
);
});
test('persisted false still keeps exact match only', async ({ request }) => {
test('random subdomain scope allows a manually entered subdomain', async ({ request }) => {
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
const uniqueName = `subdomain-default-${Date.now()}`;
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: uniqueName, domain: SUBDOMAIN },
data: { name: uniqueName, domain: RANDOM_SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
expect(res.ok()).toBe(true);
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
});
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
@@ -184,13 +186,43 @@ test.describe('Create Address Subdomain Match', () => {
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
});
test('deleted random subdomain address can be recreated manually', async ({ request }) => {
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
const name = `subreuse${Date.now()}`;
const firstCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
data: { name, domain: TEST_DOMAIN, enableRandomSubdomain: true },
});
expect(firstCreate.ok()).toBe(true);
const firstAddress = await firstCreate.json();
const generatedDomain = firstAddress.address.split('@')[1];
expect(generatedDomain).not.toBe(TEST_DOMAIN);
const firstDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${firstAddress.jwt}` },
});
expect(firstDelete.ok()).toBe(true);
const secondCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
data: { name, domain: generatedDomain },
});
expect(secondCreate.ok()).toBe(true);
const secondAddress = await secondCreate.json();
expect(secondAddress.address).toBe(firstAddress.address);
const secondDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${secondAddress.jwt}` },
});
expect(secondDelete.ok()).toBe(true);
});
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');

View File

@@ -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);
}
});
});

View File

@@ -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);
}
}
}
});
});

View File

@@ -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();
}
});
});

View File

@@ -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();
}

View File

@@ -0,0 +1,50 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL, TEST_DOMAIN, deleteAddress } from '../../fixtures/test-helpers';
test('create an address with a custom subdomain from the UI', async ({ page, request }) => {
let jwt: string | undefined;
try {
await page.goto(`${FRONTEND_URL}/en/`);
await page.getByRole('button', { name: 'Create New Email' }).click();
const name = `subui${Date.now()}`;
const createForm = page.locator('.n-tab-pane:visible form');
await createForm.locator('.n-input-group .n-input input').fill(name);
const domainSelect = createForm.locator('.n-input-group .n-select');
await expect(domainSelect.locator('input')).toHaveCount(0);
const normalSubdomain = createForm.getByRole('radio', { name: 'Normal Domain' });
const randomSubdomain = createForm.getByRole('radio', { name: 'Use Random Subdomain' });
const customSubdomain = createForm.getByRole('radio', { name: 'Use Custom Subdomain' });
await expect(normalSubdomain).toBeChecked();
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
await expect(normalSubdomain).not.toBeChecked();
await expect(randomSubdomain).toBeChecked();
await expect(customSubdomain).not.toBeChecked();
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
await expect(randomSubdomain).not.toBeChecked();
await expect(customSubdomain).toBeChecked();
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
await expect(randomSubdomain).toBeChecked();
await expect(customSubdomain).not.toBeChecked();
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
await createForm.getByRole('button', { name: 'Create New Email' }).click();
const domain = `team.${TEST_DOMAIN}`;
const address = `tmp${name}@${domain}`;
await expect(page.locator('code').getByText(address, { exact: true })).toBeVisible();
await page.waitForFunction(() => Boolean(localStorage.getItem('jwt')));
jwt = await page.evaluate(() => localStorage.getItem('jwt') || undefined);
expect(jwt).toBeTruthy();
} finally {
if (jwt) await deleteAddress(request, jwt);
}
});

View File

@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL, WORKER_URL } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test('persists the selected D1 plan and restores it after reload', async ({ page, request }) => {
const seedResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'd1_storage_plan', value: 'free' },
});
expect(seedResponse.ok()).toBe(true);
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'qucickSetup');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
const storagePanel = page.locator('.storage-panel');
const planSelect = storagePanel.locator('.plan-select .n-select');
await expect(storagePanel.getByText('Current Database Size', { exact: true })).toBeVisible();
await expect(storagePanel.getByText('Database Capacity Limit', { exact: true })).toBeVisible();
await expect(storagePanel.getByText('Capacity Usage', { exact: true })).toBeVisible();
await expect(planSelect).toContainText('Free');
await planSelect.click();
await page.locator('.n-base-select-option').filter({ hasText: 'Workers Paid' }).click();
await expect(page.getByText('Workers plan saved')).toBeVisible();
await expect(storagePanel).toContainText('10.0 GB');
await page.reload();
await expect(planSelect).toContainText('Workers Paid');
await expect(storagePanel).toContainText('10.0 GB');
});

View File

@@ -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();
}
}
});
});

View File

@@ -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();
}

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.8.0",
"version": "1.11.1",
"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",
"naive-ui": "^2.45.0",
"postal-mime": "^2.7.6",
"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.11",
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1",
"wrangler": "^4.124.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

4186
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -5,6 +5,7 @@ import axios from 'axios'
import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
import { sanitizeHtml } from '../utils/sanitize-html'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
@@ -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)
});
}
});

View File

@@ -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>

View File

@@ -97,7 +97,7 @@ const buildLocalOptions = (excludeAddresses = new Set()) => {
const buildUserOptions = async () => {
const children = [];
try {
const { results } = await api.fetch(`/user_api/bind_address`);
const { results } = await api.fetch(`/user_api/bind_address?limit=100&offset=0`);
for (const row of results || []) {
const address = row.address || row.name;
if (!address) continue;

View File

@@ -60,7 +60,7 @@ const props = defineProps({
const localFilterKeyword = ref('')
const {
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
isDark, mailboxSplitSize, mailListView, mailListPreviewLineClamp, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
@@ -71,6 +71,12 @@ const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const mailListPreviewLineClampValue = computed(() => {
const value = Number(mailListPreviewLineClamp.value)
if (!Number.isFinite(value)) return 0
return Math.min(5, Math.max(0, Math.round(value)))
})
// Computed property for filtered data (only filter current page)
const data = computed(() => {
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
@@ -183,7 +189,7 @@ const refresh = async () => {
count.value = totalCount;
}
curMail.value = null;
if (!isMobile.value && data.value.length > 0) {
if (!isMobile.value && !mailListView.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
@@ -202,6 +208,11 @@ const backFirstPageAndRefresh = async () => {
const clickRow = async (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
curMail.value = row;
return;
}
if (mailListView.value && curMail.value?.id === row.id) {
curMail.value = null;
return;
}
curMail.value = row;
@@ -375,8 +386,13 @@ onBeforeUnmount(() => {
clearable />
</n-space>
</div>
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<n-split class="left" direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange" v-if="!mailListView || curMail">
<template #resize-trigger>
<div class="split-handle">
<div class="split-handle__grip" />
</div>
</template>
<template #1>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
@@ -412,15 +428,25 @@ onBeforeUnmount(() => {
</template>
<template #2>
<div v-if="curMail" style="margin: 8px;">
<n-flex justify="space-between">
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevMail') }}
</n-button>
<n-flex justify="space-between" align="center">
<n-space :wrap="false" align="center">
<n-button v-if="mailListView" @click="curMail = null" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('backToList') }}
</n-button>
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevMail') }}
</n-button>
</n-space>
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
<template #icon>
<n-icon>
@@ -446,6 +472,48 @@ onBeforeUnmount(() => {
</n-card>
</template>
</n-split>
<div v-else class="mail-list-scroll">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
<n-thing class="mail-list-thing">
<template #header>
<n-ellipsis class="mail-list-title">
{{ row.subject }}
</n-ellipsis>
</template>
<template #description>
<div class="mail-list-meta">
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
<n-ellipsis class="mail-list-meta-text">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
</n-tag>
<n-tag v-if="showEMailTo" type="info">
<n-ellipsis class="mail-list-meta-text">
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</div>
</template>
<n-ellipsis v-if="row.text && mailListPreviewLineClampValue > 0"
:line-clamp="mailListPreviewLineClampValue" class="mail-list-preview" :tooltip="false">
{{ row.text }}
</n-ellipsis>
</n-thing>
</n-list-item>
</n-list>
</div>
</div>
<div class="left" v-else>
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
@@ -555,8 +623,80 @@ onBeforeUnmount(() => {
height: 100%;
}
.mail-list-scroll {
overflow-y: auto;
overflow-x: hidden;
min-height: 60vh;
max-height: 100vh;
}
.mail-list-thing,
.mail-list-title,
.mail-list-preview {
min-width: 0;
max-width: 100%;
}
.mail-list-thing,
.mail-list-preview {
width: 100%;
}
.mail-list-thing :deep(.n-thing-main),
.mail-list-thing :deep(.n-thing-header),
.mail-list-thing :deep(.n-thing-header__title),
.mail-list-thing :deep(.n-thing-main__description),
.mail-list-thing :deep(.n-thing-main__content) {
min-width: 0;
}
.mail-list-meta {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
max-width: 100%;
}
.mail-list-meta :deep(.n-tag) {
max-width: 100%;
}
.mail-list-meta-text {
max-width: min(240px, 100%);
}
.mail-list-preview {
display: -webkit-box;
overflow-wrap: anywhere;
opacity: 0.7;
}
.mail-list-scroll :deep(.n-list-item__main) {
min-width: 0;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
.split-handle {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.split-handle__grip {
width: 4px;
height: 32px;
border-radius: 2px;
background-color: var(--n-resize-trigger-color);
transition: background-color 0.2s;
}
.split-handle:hover .split-handle__grip {
background-color: var(--n-resize-trigger-color-hover);
}
</style>

View File

@@ -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;

View File

@@ -210,8 +210,13 @@ onMounted(async () => {
</n-button>
</n-space>
</div>
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<n-split direction="horizontal" :max="0.75" :min="0" :resize-trigger-size="8"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #resize-trigger>
<div class="split-handle">
<div class="split-handle__grip" />
</div>
</template>
<template #1>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
@@ -379,4 +384,23 @@ pre {
white-space: pre-wrap;
word-wrap: break-word;
}
.split-handle {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.split-handle__grip {
width: 4px;
height: 32px;
border-radius: 2px;
background-color: var(--n-resize-trigger-color);
transition: background-color 0.2s;
}
.split-handle:hover .split-handle__grip {
background-color: var(--n-resize-trigger-color-hover);
}
</style>

View File

@@ -1,4 +1,16 @@
export const deMessages = {
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Workers-Tarif",
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare-Workers-Tarif auswählen",
"views.admin.DatabaseManager.planSaved": "Workers-Tarif gespeichert",
"views.admin.DatabaseManager.single_database_limit": "Datenbank-Kapazitätslimit",
"views.admin.DatabaseManager.storage_description": "Vergleiche die aktuelle Datenbankgröße mit den Tariflimits.",
"views.admin.DatabaseManager.storage_tip": "Die Auslastung wird anhand der aktuellen Datenbankgröße und des gewählten Tariflimits berechnet.",
"views.admin.DatabaseManager.storage_title": "D1-Speicherkapazität",
"views.admin.DatabaseManager.storage_usage": "Kapazitätsauslastung",
"views.admin.DatabaseManager.unavailable": "Nicht verfügbar",
"views.index.SimpleIndex.mailCount": "{current} / {total} E-Mails",
"views.admin.Statistics.activeAddressCount30days": "Aktive Adressanzahl in 30 Tagen",
"views.admin.Statistics.activeAddressCount7days": "Aktive Adressanzahl in 7 Tagen",
@@ -6,9 +18,9 @@ export const deMessages = {
"views.Admin.about": "Über",
"views.Header.accessHeader": "Zugangspasswort",
"views.Admin.account": "Konto",
"views.Index.accountSettings": "Kontoeinstellungen",
"views.Index.accountSettings": "E-Mail-Einstellungen",
"views.Admin.account_settings": "Kontoeinstellungen",
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
"views.index.SimpleIndex.accountSettings": "E-Mail-Einstellungen",
"views.index.Attachment.action": "Aktion",
"views.admin.SenderAccess.action": "Aktion",
"views.user.UserSettings.actions": "Aktionen",
@@ -60,8 +72,8 @@ export const deMessages = {
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du wirklich dein Konto und alle zugehörigen E-Mails löschen?",
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen? Dein Benutzerkonto wird dabei nicht gelöscht.",
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
@@ -74,6 +86,7 @@ export const deMessages = {
"components.AiExtractInfo.authLink": "Authentifizierungslink",
"views.admin.Maintenance.autoCleanup": "Automatische Bereinigung",
"components.MailBox.autoRefresh": "Automatische Aktualisierung",
"components.MailBox.backToList": "Zurück zur Liste",
"views.common.Appearance.autoRefreshInterval": "Automatisches Aktualisierungsintervall (s)",
"views.Index.auto_reply": "Automatische Antwort",
"views.index.AutoReply.autoReply": "Automatische Antwort",
@@ -97,7 +110,7 @@ export const deMessages = {
"views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
"views.index.AccountSettings.changePassword": "Passwort ändern",
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
"views.admin.UserManagement.changeRole": "Ändern Rolle",
"components.SendBox.showCode": "Ansicht des Originalcodes umschalten",
"views.admin.Telegram.status": "Status prüfen",
@@ -169,7 +182,7 @@ export const deMessages = {
"views.admin.UserManagement.delete": "Löschen",
"views.admin.AccountSettings.delete_rule": "Löschen",
"views.admin.Maintenance.deleteCustomSql": "Löschen",
"views.index.AccountSettings.deleteAccount": "Konto löschen",
"views.index.AccountSettings.deleteAccount": "E-Mail-Adresse löschen",
"views.admin.Account.deleteAccount": "Konto löschen",
"views.user.UserSettings.deletePasskey": "Passkey löschen",
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
@@ -281,7 +294,7 @@ export const deMessages = {
"views.user.UserSettings.logout": "Abmelden",
"views.user.BindAddress.logout": "Abmelden",
"views.Admin.logout": "Abmelden",
"views.index.AccountSettings.logout": "Abmelden",
"views.index.AccountSettings.logout": "Von aktueller E-Mail-Adresse abmelden",
"views.Admin.logoutSuccess": "Erfolgreich abgemeldet",
"views.admin.UserOauth2Settings.mailAllowList": "E-Mail-Adressfreigabeliste",
"views.admin.UserSettings.mailAllowList": "E-Mail-Adressfreigabeliste",
@@ -299,7 +312,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 +346,8 @@ export const deMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Adressliste ohne Guthabenlimit",
"views.index.SimpleIndex.noMails": "Keine E-Mails gefunden",
"views.admin.RoleAddressConfig.noRolesAvailable": "In der Systemkonfiguration sind keine Rollen verfügbar",
"views.index.SendMail.requestAccessTip": "Noch kein Sendeguthaben vorhanden. Wenn der Administrator ein Standardguthaben aktiviert hat, wird es automatisch zugewiesen; andernfalls Zugriff anfordern oder den Administrator kontaktieren.",
"views.index.SendMail.requestAccessTip": "Sendeberechtigung und Guthaben werden für jede E-Mail-Adresse separat verwaltet. Die aktuelle Adresse {address} hat kein verfügbares Guthaben. Fordere die Berechtigung für diese Adresse an oder kontaktiere den Administrator.",
"views.index.SendMail.requestSuccess": "Sendeberechtigung für die aktuelle Adresse angefordert",
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
@@ -461,7 +478,7 @@ export const deMessages = {
"views.admin.UserAddressManagement.send_count": "Sendeanzahl",
"views.Index.sendmail": "E-Mail senden",
"views.Admin.sendMail": "E-Mail senden",
"views.index.SendMail.send_balance": "Verbleibendes Sendeguthaben",
"views.index.SendMail.send_balance": "Sendeguthaben der aktuellen Adresse",
"views.admin.Statistics.sendMailCount": "Anzahl gesendeter E-Mails",
"views.admin.AccountSettings.send_mail_limit": "Sende-Limit",
"views.user.UserLogin.sendVerificationCode": "Bestätigungscode senden",
@@ -522,7 +539,7 @@ export const deMessages = {
"views.index.LocalAddress.tip": "Diese Adressen werden in deinem Browser gespeichert und können verloren gehen, wenn du den Browser-Cache leerst.",
"views.admin.UserOauth2Settings.tip": "Drittanbieter-Login verwendet automatisch die E-Mail-Adresse des Benutzers zur Registrierung eines Kontos (dieselbe E-Mail gilt als dasselbe Konto). Das Konto entspricht dem regulär registrierten Konto, und das Passwort kann auch über „Passwort vergessen“ gesetzt werden.",
"views.admin.AccountSettings.send_mail_limit_tip": "Dies gilt für alle Sendekanäle. Verwende -1 für unbegrenzt und 0, um das Senden zu blockieren.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Dies unterscheidet sich von RANDOM_SUBDOMAIN_DOMAINS: Dieser Schalter erlaubt API-Aufrufern, benutzerdefinierte Subdomains direkt anzugeben, während die zufällige Subdomain nur bei der Erstellung automatisch erzeugt wird.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS erlaubt bereits zufällige oder manuelle Subdomains für gelistete Basisdomains. Dieser Schalter erlaubt API-Aufrufern zusätzlich Subdomains unter anderen erlaubten Basisdomains.",
"views.index.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
"views.admin.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
"views.common.Appearance.top": "oben",
@@ -577,8 +594,12 @@ 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.admin.CreateAccount.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
"views.admin.CreateAccount.normalSubdomain": "Normale Domain",
"views.common.Login.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
"views.common.Login.normalSubdomain": "Normale Domain",
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
"views.admin.AiExtractSettings.allowListTip": "Der Platzhalter * passt auf beliebige Zeichen; z. B. passt *{'@'}example.com auf alle Adressen der Domain example.com",
"views.Admin.workerconfig": "Worker-Konfiguration",
"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 +607,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"
}

View File

@@ -1,4 +1,16 @@
export const esMessages = {
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Plan de Workers",
"views.admin.DatabaseManager.plan_placeholder": "Selecciona tu plan de Cloudflare Workers",
"views.admin.DatabaseManager.planSaved": "Plan de Workers guardado",
"views.admin.DatabaseManager.single_database_limit": "Límite de capacidad de la base de datos",
"views.admin.DatabaseManager.storage_description": "Compara el tamaño actual de la base de datos con los límites de tu plan.",
"views.admin.DatabaseManager.storage_tip": "El uso se calcula con el tamaño actual de la base de datos y el límite del plan seleccionado.",
"views.admin.DatabaseManager.storage_title": "Capacidad de almacenamiento D1",
"views.admin.DatabaseManager.storage_usage": "Uso de capacidad",
"views.admin.DatabaseManager.unavailable": "No disponible",
"views.index.SimpleIndex.mailCount": "{current} / {total} correos",
"views.admin.Statistics.activeAddressCount30days": "Cantidad de direcciones activas en 30 días",
"views.admin.Statistics.activeAddressCount7days": "Cantidad de direcciones activas en 7 días",
@@ -6,9 +18,9 @@ export const esMessages = {
"views.Admin.about": "Acerca de",
"views.Header.accessHeader": "Contraseña de acceso",
"views.Admin.account": "Cuenta",
"views.Index.accountSettings": "Configuración de la cuenta",
"views.Index.accountSettings": "Configuración del correo",
"views.Admin.account_settings": "Configuración de la cuenta",
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
"views.index.SimpleIndex.accountSettings": "Configuración del correo",
"views.index.Attachment.action": "Acción",
"views.admin.SenderAccess.action": "Acción",
"views.user.UserSettings.actions": "Acciones",
@@ -60,8 +72,8 @@ export const esMessages = {
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar tu cuenta y todos sus correos?",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección de correo y todos sus correos? Esto no elimina tu cuenta de usuario.",
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
@@ -74,6 +86,7 @@ export const esMessages = {
"components.AiExtractInfo.authLink": "Enlace de autenticación",
"views.admin.Maintenance.autoCleanup": "Limpieza automática",
"components.MailBox.autoRefresh": "Actualización automática",
"components.MailBox.backToList": "Volver a la lista",
"views.common.Appearance.autoRefreshInterval": "Intervalo de actualización automática (s)",
"views.Index.auto_reply": "Respuesta automática",
"views.index.AutoReply.autoReply": "Respuesta automática",
@@ -97,7 +110,7 @@ export const esMessages = {
"views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
"views.index.AccountSettings.changePassword": "Cambiar contraseña",
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
"views.admin.UserManagement.changeRole": "Cambiar Rol",
"components.SendBox.showCode": "Cambiar vista del código original",
"views.admin.Telegram.status": "Ver estado",
@@ -169,7 +182,7 @@ export const esMessages = {
"views.admin.UserManagement.delete": "Eliminar",
"views.admin.AccountSettings.delete_rule": "Eliminar",
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
"views.index.AccountSettings.deleteAccount": "Eliminar dirección de correo",
"views.admin.Account.deleteAccount": "Eliminar cuenta",
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
@@ -281,7 +294,7 @@ export const esMessages = {
"views.user.UserSettings.logout": "Cerrar sesión",
"views.user.BindAddress.logout": "Cerrar sesión",
"views.Admin.logout": "Cerrar sesión",
"views.index.AccountSettings.logout": "Cerrar sesión",
"views.index.AccountSettings.logout": "Cerrar sesión de la dirección de correo actual",
"views.Admin.logoutSuccess": "Sesión cerrada correctamente",
"views.admin.UserOauth2Settings.mailAllowList": "Lista blanca de direcciones de correo",
"views.admin.UserSettings.mailAllowList": "Lista blanca de direcciones de correo",
@@ -299,7 +312,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 +346,8 @@ export const esMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de direcciones sin límite de saldo",
"views.index.SimpleIndex.noMails": "No se encontraron correos",
"views.admin.RoleAddressConfig.noRolesAvailable": "No hay roles disponibles en la configuración del sistema",
"views.index.SendMail.requestAccessTip": "Todavía no hay saldo de envío. Si el administrador activó un saldo por defecto, se asignará automáticamente; si no, solicita acceso o contacta con el administrador.",
"views.index.SendMail.requestAccessTip": "El permiso y el saldo de envío se gestionan por separado para cada dirección. La dirección actual, {address}, no tiene saldo disponible. Solicita permiso para esta dirección o contacta con el administrador.",
"views.index.SendMail.requestSuccess": "Permiso de envío solicitado para la dirección actual",
"components.SendBox.emptySent": "No hay correos enviados",
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
@@ -461,7 +478,7 @@ export const esMessages = {
"views.admin.UserAddressManagement.send_count": "Cantidad enviada",
"views.Index.sendmail": "Enviar correo",
"views.Admin.sendMail": "Enviar correo",
"views.index.SendMail.send_balance": "Saldo restante de envío",
"views.index.SendMail.send_balance": "Saldo de envío de la dirección actual",
"views.admin.Statistics.sendMailCount": "Cantidad de correos enviados",
"views.admin.AccountSettings.send_mail_limit": "Límite de envío",
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificación",
@@ -522,7 +539,7 @@ export const esMessages = {
"views.index.LocalAddress.tip": "Estas direcciones se guardan en tu navegador y podrían perderse si borras la caché.",
"views.admin.UserOauth2Settings.tip": "El inicio de sesión de terceros usará automáticamente el correo del usuario para registrar una cuenta (el mismo correo se considera la misma cuenta). También puedes establecer la contraseña con “olvidé mi contraseña”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Se aplica a todos los canales de envío. Usa -1 para ilimitado y 0 para bloquear el envío.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Esto es diferente de RANDOM_SUBDOMAIN_DOMAINS: este interruptor permite indicar subdominios personalizados; el subdominio aleatorio solo genera uno al crear.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS ya permite subdominios aleatorios o manuales en los dominios base listados. Este interruptor permite además que la API especifique subdominios en otros dominios base permitidos.",
"views.index.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.admin.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
"views.common.Appearance.top": "arriba",
@@ -577,8 +594,12 @@ 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.admin.CreateAccount.enableCustomSubdomain": "Usar subdominio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Dominio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdominio personalizado",
"views.common.Login.normalSubdomain": "Dominio normal",
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
"views.admin.AiExtractSettings.allowListTip": "El comodín * coincide con cualquier carácter; p. ej., *{'@'}example.com coincide con todas las direcciones del dominio example.com",
"views.Admin.workerconfig": "Configuración del Worker",
"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 +607,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"
}

View File

@@ -1,4 +1,16 @@
export const jaMessages = {
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Workers プラン",
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare Workers プランを選択",
"views.admin.DatabaseManager.planSaved": "Workers プランを保存しました",
"views.admin.DatabaseManager.single_database_limit": "データベース容量上限",
"views.admin.DatabaseManager.storage_description": "現在のデータベースサイズとプラン上限を比較します。",
"views.admin.DatabaseManager.storage_tip": "使用率は現在のデータベースサイズと選択したプラン上限から計算されます。",
"views.admin.DatabaseManager.storage_title": "D1 ストレージ容量",
"views.admin.DatabaseManager.storage_usage": "容量使用率",
"views.admin.DatabaseManager.unavailable": "利用不可",
"views.index.SimpleIndex.mailCount": "{current} / {total} 件のメール",
"views.admin.Statistics.activeAddressCount30days": "30日間のアクティブなアドレス数",
"views.admin.Statistics.activeAddressCount7days": "7日間のアクティブなアドレス数",
@@ -6,9 +18,9 @@ export const jaMessages = {
"views.Admin.about": "概要",
"views.Header.accessHeader": "アクセス用パスワード",
"views.Admin.account": "アカウント",
"views.Index.accountSettings": "アカウント設定",
"views.Index.accountSettings": "メール設定",
"views.Admin.account_settings": "アカウント設定",
"views.index.SimpleIndex.accountSettings": "アカウント設定",
"views.index.SimpleIndex.accountSettings": "メール設定",
"views.index.Attachment.action": "操作",
"views.admin.SenderAccess.action": "操作",
"views.user.UserSettings.actions": "操作",
@@ -60,8 +72,8 @@ export const jaMessages = {
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
@@ -74,6 +86,7 @@ export const jaMessages = {
"components.AiExtractInfo.authLink": "認証リンク",
"views.admin.Maintenance.autoCleanup": "自動クリーンアップ",
"components.MailBox.autoRefresh": "自動更新",
"components.MailBox.backToList": "リストに戻る",
"views.common.Appearance.autoRefreshInterval": "自動更新間隔 (秒)",
"views.Index.auto_reply": "自動返信",
"views.index.AutoReply.autoReply": "自動返信",
@@ -97,7 +110,7 @@ export const jaMessages = {
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
"views.index.AccountSettings.changePassword": "パスワードを変更",
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
"views.admin.UserManagement.changeRole": "ロールを変更",
"components.SendBox.showCode": "元のコード表示を切り替え",
"views.admin.Telegram.status": "ステータスを確認",
@@ -169,7 +182,7 @@ export const jaMessages = {
"views.admin.UserManagement.delete": "削除",
"views.admin.AccountSettings.delete_rule": "削除",
"views.admin.Maintenance.deleteCustomSql": "削除",
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
"views.index.AccountSettings.deleteAccount": "メールアドレスを削除",
"views.admin.Account.deleteAccount": "アカウントを削除",
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
"views.admin.AccountSettings.delete_success": "削除しました",
@@ -281,7 +294,7 @@ export const jaMessages = {
"views.user.UserSettings.logout": "ログアウト",
"views.user.BindAddress.logout": "ログアウト",
"views.Admin.logout": "ログアウト",
"views.index.AccountSettings.logout": "ログアウト",
"views.index.AccountSettings.logout": "現在のメールアドレスからログアウト",
"views.Admin.logoutSuccess": "ログアウトしました",
"views.admin.UserOauth2Settings.mailAllowList": "メールアドレス許可リスト",
"views.admin.UserSettings.mailAllowList": "メールアドレス許可リスト",
@@ -299,7 +312,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 +346,8 @@ export const jaMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
"views.index.SimpleIndex.noMails": "メールが見つかりません",
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者連絡してください。",
"views.index.SendMail.requestAccessTip": "送信権限と残高はメールアドレスごとに管理されます。現在のアドレス {address} には利用可能な残高がありません。このアドレスの権限申請するか、管理者連絡してください。",
"views.index.SendMail.requestSuccess": "現在のアドレスの送信権限を申請しました",
"components.SendBox.emptySent": "送信済みメールはありません",
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
"views.Admin.userOauth2Settings": "OAuth2設定",
@@ -461,7 +478,7 @@ export const jaMessages = {
"views.admin.UserAddressManagement.send_count": "送信数",
"views.Index.sendmail": "メール送信",
"views.Admin.sendMail": "メール送信",
"views.index.SendMail.send_balance": "残り送信枠",
"views.index.SendMail.send_balance": "現在のアドレスの送信残高",
"views.admin.Statistics.sendMailCount": "送信メール数",
"views.admin.AccountSettings.send_mail_limit": "送信上限",
"views.user.UserLogin.sendVerificationCode": "認証コードを送信",
@@ -522,7 +539,7 @@ export const jaMessages = {
"views.index.LocalAddress.tip": "これらのアドレスはブラウザに保存されており、キャッシュを消すと失われる可能性があります。",
"views.admin.UserOauth2Settings.tip": "サードパーティログインではユーザーのメールアドレスで自動的にアカウント登録されます(同じメールは同一アカウントとして扱われます)。「パスワードを忘れた」からパスワード設定も可能です。",
"views.admin.AccountSettings.send_mail_limit_tip": "すべての送信チャネルに適用されます。-1 は無制限、0 は送信禁止です。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "これは RANDOM_SUBDOMAIN_DOMAINS と異なります。この設定では API 呼び出し側が独自サブドメインを指定でき、ランダムサブドメインは作成時に自動生成されるだけです。",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS に列挙したベースドメインでは、ランダムまたは手動のサブドメインを利用できます。この設定は、他の許可済みベースドメインでも API からサブドメインを指定できるようにします。",
"views.index.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.admin.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.common.Appearance.top": "上",
@@ -577,8 +594,12 @@ 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.admin.CreateAccount.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.admin.CreateAccount.normalSubdomain": "通常ドメイン",
"views.common.Login.enableCustomSubdomain": "カスタムサブドメインを使用",
"views.common.Login.normalSubdomain": "通常ドメイン",
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
"views.Admin.workerconfig": "Worker設定",
"views.admin.AccountSettings.create_address_subdomain_match_env_locked": "Worker 環境変数 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH は現在 false です。管理画面のスイッチは保存できますが、env を有効化または削除するまで反映されません。",
@@ -586,5 +607,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": "ユーザー名"
}

View File

@@ -1,4 +1,16 @@
export const ptBRMessages = {
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
"views.admin.DatabaseManager.free_plan": "Free",
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
"views.admin.DatabaseManager.plan": "Plano do Workers",
"views.admin.DatabaseManager.plan_placeholder": "Selecione seu plano do Cloudflare Workers",
"views.admin.DatabaseManager.planSaved": "Plano do Workers salvo",
"views.admin.DatabaseManager.single_database_limit": "Limite de capacidade do banco de dados",
"views.admin.DatabaseManager.storage_description": "Compare o tamanho atual do banco de dados com os limites do seu plano.",
"views.admin.DatabaseManager.storage_tip": "O uso é calculado com o tamanho atual do banco de dados e o limite do plano selecionado.",
"views.admin.DatabaseManager.storage_title": "Capacidade de armazenamento D1",
"views.admin.DatabaseManager.storage_usage": "Uso da capacidade",
"views.admin.DatabaseManager.unavailable": "Indisponível",
"views.index.SimpleIndex.mailCount": "{current} / {total} e-mails",
"views.admin.Statistics.activeAddressCount30days": "Quantidade de endereços ativos em 30 dias",
"views.admin.Statistics.activeAddressCount7days": "Quantidade de endereços ativos em 7 dias",
@@ -6,9 +18,9 @@ export const ptBRMessages = {
"views.Admin.about": "Sobre",
"views.Header.accessHeader": "Senha de acesso",
"views.Admin.account": "Conta",
"views.Index.accountSettings": "Configurações da conta",
"views.Index.accountSettings": "Configurações de e-mail",
"views.Admin.account_settings": "Configurações da conta",
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
"views.index.SimpleIndex.accountSettings": "Configurações de e-mail",
"views.index.Attachment.action": "Ação",
"views.admin.SenderAccess.action": "Ação",
"views.user.UserSettings.actions": "Ações",
@@ -60,8 +72,8 @@ export const ptBRMessages = {
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir sua conta e todos os e-mails dela?",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço de e-mail e todas as mensagens dele? Isso não exclui sua conta de usuário.",
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
@@ -74,6 +86,7 @@ export const ptBRMessages = {
"components.AiExtractInfo.authLink": "Link de autenticação",
"views.admin.Maintenance.autoCleanup": "Limpeza automática",
"components.MailBox.autoRefresh": "Atualização automática",
"components.MailBox.backToList": "Voltar para a lista",
"views.common.Appearance.autoRefreshInterval": "Intervalo de atualização automática (s)",
"views.Index.auto_reply": "Resposta automática",
"views.index.AutoReply.autoReply": "Resposta automática",
@@ -97,7 +110,7 @@ export const ptBRMessages = {
"views.user.AddressManagement.changeMailAddress": "Alterar endereço",
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
"views.index.AccountSettings.changePassword": "Alterar senha",
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
"views.admin.UserManagement.changeRole": "Alterar Função",
"components.SendBox.showCode": "Alternar visualização do código original",
"views.admin.Telegram.status": "Ver status",
@@ -169,7 +182,7 @@ export const ptBRMessages = {
"views.admin.UserManagement.delete": "Excluir",
"views.admin.AccountSettings.delete_rule": "Excluir",
"views.admin.Maintenance.deleteCustomSql": "Excluir",
"views.index.AccountSettings.deleteAccount": "Excluir conta",
"views.index.AccountSettings.deleteAccount": "Excluir endereço de e-mail",
"views.admin.Account.deleteAccount": "Excluir conta",
"views.user.UserSettings.deletePasskey": "Excluir passkey",
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
@@ -281,7 +294,7 @@ export const ptBRMessages = {
"views.user.UserSettings.logout": "Sair",
"views.user.BindAddress.logout": "Sair",
"views.Admin.logout": "Sair",
"views.index.AccountSettings.logout": "Sair",
"views.index.AccountSettings.logout": "Sair do endereço de e-mail atual",
"views.Admin.logoutSuccess": "Logout realizado com sucesso",
"views.admin.UserOauth2Settings.mailAllowList": "Lista branca de endereços de e-mail",
"views.admin.UserSettings.mailAllowList": "Lista branca de endereços de e-mail",
@@ -299,7 +312,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 +346,8 @@ export const ptBRMessages = {
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de endereços sem limite de saldo",
"views.index.SimpleIndex.noMails": "Nenhum e-mail encontrado",
"views.admin.RoleAddressConfig.noRolesAvailable": "Nenhuma função disponível na configuração do sistema",
"views.index.SendMail.requestAccessTip": "Ainda não há saldo de envio. Se o administrador ativou um saldo padrão, ele será atribuído automaticamente; caso contrário, solicite acesso ou fale com o administrador.",
"views.index.SendMail.requestAccessTip": "A permissão e o saldo de envio são gerenciados separadamente para cada endereço. O endereço atual, {address}, não tem saldo disponível. Solicite permissão para este endereço ou fale com o administrador.",
"views.index.SendMail.requestSuccess": "Permissão de envio solicitada para o endereço atual",
"components.SendBox.emptySent": "Nenhum e-mail enviado",
"views.admin.RoleAddressConfig.notConfigured": "Não configurado (usar configurações globais)",
"views.Admin.userOauth2Settings": "Configurações de OAuth2",
@@ -461,7 +478,7 @@ export const ptBRMessages = {
"views.admin.UserAddressManagement.send_count": "Quantidade enviada",
"views.Index.sendmail": "Enviar e-mail",
"views.Admin.sendMail": "Enviar e-mail",
"views.index.SendMail.send_balance": "Saldo restante de envio",
"views.index.SendMail.send_balance": "Saldo de envio do endereço atual",
"views.admin.Statistics.sendMailCount": "Quantidade de e-mails enviados",
"views.admin.AccountSettings.send_mail_limit": "Limite de envio",
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificação",
@@ -522,7 +539,7 @@ export const ptBRMessages = {
"views.index.LocalAddress.tip": "Esses endereços ficam armazenados no navegador e podem ser perdidos se você limpar o cache.",
"views.admin.UserOauth2Settings.tip": "O login de terceiros usará automaticamente o e-mail do usuário para registrar uma conta (o mesmo e-mail será considerado a mesma conta). Você também pode definir a senha via “esqueci minha senha”.",
"views.admin.AccountSettings.send_mail_limit_tip": "Aplica-se a todos os canais de envio. Use -1 para ilimitado e 0 para bloquear o envio.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "Isso é diferente de RANDOM_SUBDOMAIN_DOMAINS: esta opção permite informar subdomínios personalizados; o subdomínio aleatório apenas gera um durante a criação.",
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS já permite subdomínios aleatórios ou manuais nos domínios base listados. Esta opção também permite que a API informe subdomínios em outros domínios base permitidos.",
"views.index.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.admin.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
"views.common.Appearance.top": "topo",
@@ -577,8 +594,12 @@ 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.admin.CreateAccount.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.admin.CreateAccount.normalSubdomain": "Domínio normal",
"views.common.Login.enableCustomSubdomain": "Usar subdomínio personalizado",
"views.common.Login.normalSubdomain": "Domínio normal",
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
"views.admin.AiExtractSettings.allowListTip": "O curinga * corresponde a quaisquer caracteres; ex.: *{'@'}example.com corresponde a todos os endereços do domínio example.com",
"views.Admin.workerconfig": "Configuração do Worker",
"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 +607,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"
}

View File

@@ -42,6 +42,10 @@ export const MESSAGE_REGISTRY = {
"en": "Auto Refresh",
"zh": "自动刷新"
},
"backToList": {
"en": "Back to List",
"zh": "返回列表"
},
"cancelMultiAction": {
"en": "Cancel Multi Action",
"zh": "取消多选"
@@ -186,6 +190,14 @@ export const MESSAGE_REGISTRY = {
"en": "Fullscreen",
"zh": "全屏"
},
"loadRemoteImages": {
"en": "Load Images",
"zh": "加载图片"
},
"remoteImagesBlocked": {
"en": "{count} remote resources blocked to protect your privacy",
"zh": "已阻止 {count} 项外部资源以保护隐私"
},
"reply": {
"en": "Reply",
"zh": "回复"
@@ -281,6 +293,116 @@ export const MESSAGE_REGISTRY = {
"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": {
"addressQueryTip": {
"en": "Leave blank to query all addresses",
@@ -297,8 +419,8 @@ export const MESSAGE_REGISTRY = {
"zh": "关于"
},
"accountSettings": {
"en": "Account Settings",
"zh": "账户"
"en": "Email Settings",
"zh": "邮箱设置"
},
"appearance": {
"en": "Appearance",
@@ -730,6 +852,10 @@ export const MESSAGE_REGISTRY = {
"en": "Mail Count",
"zh": "邮件数量"
},
"itemCount": {
"en": "Total",
"zh": "总数"
},
"name": {
"en": "Name",
"zh": "名称"
@@ -765,8 +891,8 @@ export const MESSAGE_REGISTRY = {
},
"views.index.AccountSettings": {
"changePassword": {
"en": "Change Password",
"zh": "修改密码"
"en": "Change Address Password",
"zh": "修改地址密码"
},
"clearInbox": {
"en": "Clear Inbox",
@@ -789,20 +915,20 @@ export const MESSAGE_REGISTRY = {
"zh": "确认密码"
},
"deleteAccount": {
"en": "Delete Account",
"zh": "删除账户"
"en": "Delete Email Address",
"zh": "删除邮箱地址"
},
"deleteAccountConfirm": {
"en": "Are you sure to delete your account and all emails for this account?",
"zh": "确定要删除你的账户和其中的所有邮件吗?"
"en": "Are you sure you want to delete this email address and all of its emails? This does not delete your user account.",
"zh": "确定要删除当前邮箱地址及其中的所有邮件吗?此操作不会删除你的用户账号。"
},
"logout": {
"en": "Logout",
"zh": "退出登录"
"en": "Log Out of Address",
"zh": "退出地址登录"
},
"logoutConfirm": {
"en": "Are you sure to logout?",
"zh": "确定要退出登录吗?"
"en": "Are you sure you want to log out of the current email address?",
"zh": "确定要退出当前邮箱地址的登录吗?"
},
"newPassword": {
"en": "New Password",
@@ -817,8 +943,8 @@ export const MESSAGE_REGISTRY = {
"zh": "密码不匹配"
},
"showAddressCredential": {
"en": "Show Address Credential",
"zh": "查看邮箱地址凭证"
"en": "Credentials & Connection Methods",
"zh": "地址凭证与连接方式"
},
"success": {
"en": "Success",
@@ -933,8 +1059,12 @@ export const MESSAGE_REGISTRY = {
"zh": "申请权限"
},
"requestAccessTip": {
"en": "No send balance yet. If your admin enabled a default balance it should be assigned automatically; otherwise request access or contact the admin.",
"zh": "当前还没有可用的发信额度。如果管理员启用了默认额度,会自动发放;否则请申请权限或联系管理员处理。"
"en": "Send permission and balance are managed separately for each email address. The current address, {address}, has no available balance. Request permission for this address or contact the admin.",
"zh": "发信权限和额度按邮箱地址独立管理。当前地址 {address} 暂无可用额度,请为此地址申请发信权限或联系管理员。"
},
"requestSuccess": {
"en": "Send permission requested for the current address",
"zh": "已为当前地址提交发信权限申请"
},
"rich text": {
"en": "Rich Text",
@@ -945,8 +1075,8 @@ export const MESSAGE_REGISTRY = {
"zh": "发送"
},
"send_balance": {
"en": "Send Mail Balance Left",
"zh": "剩余发送邮件额度"
"en": "Current Address Send Balance",
"zh": "当前地址剩余发信额度"
},
"subject": {
"en": "Subject",
@@ -979,8 +1109,8 @@ export const MESSAGE_REGISTRY = {
},
"views.index.SimpleIndex": {
"accountSettings": {
"en": "Account Settings",
"zh": "账户设置"
"en": "Email Settings",
"zh": "邮箱设置"
},
"addressCopied": {
"en": "Address copied successfully",
@@ -1564,6 +1694,14 @@ export const MESSAGE_REGISTRY = {
"en": "Current DB Version",
"zh": "当前数据库版本"
},
"current_database_size": {
"en": "Current Database Size",
"zh": "当前数据库大小"
},
"free_plan": {
"en": "Free",
"zh": "Free"
},
"init": {
"en": "Initialize Database",
"zh": "初始化数据库"
@@ -1587,6 +1725,46 @@ export const MESSAGE_REGISTRY = {
"need_migration_tip": {
"en": "Database migration is required. Please migrate the database.",
"zh": "需要迁移数据库,请迁移数据库"
},
"paid_plan": {
"en": "Workers Paid",
"zh": "Workers Paid"
},
"plan": {
"en": "Workers Plan",
"zh": "Workers 套餐"
},
"plan_placeholder": {
"en": "Select your Cloudflare Workers plan",
"zh": "请选择 Cloudflare Workers 套餐"
},
"planSaved": {
"en": "Workers plan saved",
"zh": "Workers 套餐已保存"
},
"single_database_limit": {
"en": "Database Capacity Limit",
"zh": "数据库容量上限"
},
"storage_description": {
"en": "Compare the current database size with your plan limits.",
"zh": "将当前数据库大小与套餐容量上限进行对比"
},
"storage_tip": {
"en": "Usage is calculated from the current database size and the selected plan limit.",
"zh": "使用率按当前数据库大小与所选套餐的数据库容量上限计算。"
},
"storage_title": {
"en": "D1 Storage Capacity",
"zh": "D1 存储容量"
},
"storage_usage": {
"en": "Capacity Usage",
"zh": "容量使用率"
},
"unavailable": {
"en": "Unavailable",
"zh": "暂不可用"
}
},
"views.admin.IpBlacklistSettings": {
@@ -1874,6 +2052,10 @@ export const MESSAGE_REGISTRY = {
"en": "Create New Email",
"zh": "创建新邮箱"
},
"enableCustomSubdomain": {
"en": "Use Custom Subdomain",
"zh": "使用自定义子域名"
},
"enablePrefix": {
"en": "If enable Prefix",
"zh": "是否启用前缀"
@@ -1890,9 +2072,13 @@ export const MESSAGE_REGISTRY = {
"en": "Open to auto login email link",
"zh": "打开即可自动登录邮箱的链接"
},
"normalSubdomain": {
"en": "Normal Domain",
"zh": "普通域名"
},
"randomSubdomainTip": {
"en": "When enabled, the created address will use a random subdomain. 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 +2166,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 +2187,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",
@@ -2083,8 +2285,8 @@ export const MESSAGE_REGISTRY = {
"zh": "强制开启"
},
"create_address_subdomain_match_note": {
"en": "This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.",
"zh": "这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名随机子域名功能只是在创建时自动补一个随机子域名。"
"en": "RANDOM_SUBDOMAIN_DOMAINS already allows random or manual subdomains for listed base domains. This switch additionally allows API callers to specify subdomains under other allowed base domains.",
"zh": "RANDOM_SUBDOMAIN_DOMAINS 已允许在所列基础域名随机生成或手动输入子域名;此开关额外允许 API 在其他已授权基础域名下指定子域名。"
},
"create_address_subdomain_match_tip": {
"en": "Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.",
@@ -2364,6 +2566,10 @@ export const MESSAGE_REGISTRY = {
"en": "Use Random Subdomain",
"zh": "启用随机子域名"
},
"enableCustomSubdomain": {
"en": "Use Custom Subdomain",
"zh": "使用自定义子域名"
},
"generateName": {
"en": "Generate Fake Name",
"zh": "生成随机名字"
@@ -2372,6 +2578,10 @@ export const MESSAGE_REGISTRY = {
"en": "Create New Email",
"zh": "创建新邮箱"
},
"normalSubdomain": {
"en": "Normal Domain",
"zh": "普通域名"
},
"getNewEmailTip1": {
"en": "Please input the email you want to use. only allow: ",
"zh": "请输入你想要使用的邮箱地址, 只允许: "
@@ -2413,8 +2623,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 记录,详见随机子域名文档。"
}
},
"views.admin.Webhook": {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -0,0 +1,121 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest';
import { blockRemoteContent } from '../remote-content-policy';
const T = 'https://tracker.example/p.png';
function leaks(html) {
const host = document.createElement('div');
host.innerHTML = html;
const f = [];
for (const el of host.querySelectorAll('*')) {
for (const a of el.attributes) {
if (a.name.startsWith('data-blocked-')) continue;
if (/tracker\.example/i.test(a.value)) f.push(`${el.tagName}[${a.name}]`);
}
if (el.tagName === 'STYLE' && /tracker\.example/i.test(el.textContent || '')) f.push('STYLE-text');
}
if (/tracker\.example/i.test(host.innerHTML) && !f.length) f.push('RAW');
return f;
}
const TAB = String.fromCharCode(9);
const NUL = String.fromCharCode(1);
const V = [
['noscript 突破', `<p>hi</p><noscript><b title="</noscript><img src=${T}>"></noscript>`],
['base href', `<base href="https://tracker.example/"><img src="/logo.png">`],
['iframe srcdoc', `<iframe srcdoc="&lt;img src=${T}&gt;"></iframe>`],
['script src', `<script src="${T}"></script>`],
['meta refresh', `<meta http-equiv="refresh" content="0;url=${T}">`],
['link preload', `<link rel="preload" as="image" href="${T}">`],
['link imagesrcset', `<link rel="preload" as="image" imagesrcset="${T} 1x">`],
['frame src', `<frameset><frame src="${T}"></frameset>`],
['style @import 註解', `<style>@import/**/"${T}";</style>`],
['style @import url', `<style>@import url(${T});</style>`],
['style background', `<style>.a{background:url("${T}")}</style><div class="a"></div>`],
['style image-set', `<style>.a{background:image-set('${T}' 1x)}</style>`],
['style 誘餌 url(', `<style>.a{content:"url(";background:url(${T})}</style>`],
['attr image-set', `<div style="background:image-set('${T}' 1x)">x</div>`],
['attr CSS 跳脫', `<div style="background:url(\\68 ttps://tracker.example/p.png)">x</div>`],
['attr CSS 函式名稱跳脫', `<div style="background:u\\72l(${T})">x</div>`],
['style CSS 函式名稱跳脫', `<style>.a{background:u\\72l(${T})}</style>`],
['style CSS at-rule 跳脫', `<style>@im\\70ort "${T}";</style>`],
['URL 反斜線', `<img src="https:\\\\tracker.example\\p.png">`],
['scheme 無斜線', `<img src="https:tracker.example/p.png">`],
['data:text/html iframe', `<iframe src="data:text/html,&lt;img src=${T}&gt;"></iframe>`],
['quoted src', `<img src="${T}">`],
['unquoted src', `<img src=${T}>`],
['srcset', `<img srcset="${T} 1x">`],
['src+srcset', `<img src="${T}" srcset="https://tracker.example/2x.png 2x">`],
['source srcset', `<picture><source srcset="${T}"><img src="cid:x"></picture>`],
['td background', `<table><tr><td background="${T}">x</td></tr></table>`],
['svg image href', `<svg><image href="${T}"/></svg>`],
['video poster', `<video poster="${T}"></video>`],
['tab 分割 scheme', `<img src="ht${TAB}tps://tracker.example/p.png">`],
['C0 控制字元前綴', `<img src="${NUL}${T}">`],
['entity scheme', `<img src="https&#58;//tracker.example/p.png">`],
['protocol-relative', `<img src="//tracker.example/p.png">`],
];
const KEEP = [
['cid', '<img src="cid:p@x">', 'cid:p@x'],
['data image', '<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7">', 'data:image/gif'],
['blob', '<img src="blob:https://app.example/8f2c">', 'blob:'],
['相對路徑', '<img src="/assets/logo.png">', '/assets/logo.png'],
['排版 CSS', '<table><tr><td style="padding:8px;color:#333">hi</td></tr></table>', 'padding:8px'],
['style 區塊排版', '<style>.a{color:red;font-size:14px}</style><p class="a">x</p>', 'font-size:14px'],
['data: 於 CSS', '<div style="background:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7)">x</div>', 'data:image/gif'],
['外部 a 連結', `<a href="${T}">open</a>`, T],
['外部 area 連結', `<map name="m"><area href="${T}" coords="0,0,1,1"></map>`, T],
];
describe('攻擊向量', () => {
it.each(V)('%s', (n, html) => {
const r = blockRemoteContent(html);
expect({ v: n, leaks: leaks(r.html) }).toEqual({ v: n, leaks: [] });
});
});
describe('必須保留', () => {
it.each(KEEP)('%s', (n, html, needle) => {
const r = blockRemoteContent(html);
expect({ v: n, kept: r.html.includes(needle), blocked: r.blocked })
.toEqual({ v: n, kept: true, blocked: 0 });
});
it('保留安全 CSS 跳脫與本地資源', () => {
const r = blockRemoteContent(
'<div style="font-family:\\41 rial;background:url(data:image/gif;base64,AAAA)">x</div>'
);
expect(r.blocked).toBe(0);
expect(r.html).toContain('font-family:\\41 rial');
expect(r.html).toContain('data:image/gif');
});
});
describe('阻斷計數', () => {
it('逐一計算 CSS 跳脫函式中的遠端資源', () => {
const r = blockRemoteContent(
'<div style="color:red;background:u\\72l(https://a.example/a.png),u\\72l(https://b.example/b.png)">x</div>'
);
expect(r.blocked).toBe(2);
expect(r.html).toContain('color:red');
expect(r.html).not.toContain('https://');
});
});
describe('危險導航協議', () => {
it.each([
['a javascript', '<a href="javascript:alert(1)">x</a>', 'a'],
['a data:text/html', '<a href="data:text/html,<script>alert(1)</script>">x</a>', 'a'],
['area javascript', '<map><area href="javascript:alert(1)"></map>', 'area'],
['area data:text/html', '<map><area href="data:text/html,<script>alert(1)</script>"></map>', 'area'],
])('%s', (_name, html, selector) => {
const host = document.createElement('div');
host.innerHTML = blockRemoteContent(html).html;
const node = host.querySelector(selector);
expect(node).not.toBeNull();
expect(node.hasAttribute('href')).toBe(false);
});
});

View File

@@ -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('');
});
});

View File

@@ -80,3 +80,4 @@ export function getDownloadEmlUrl(raw) {
new Blob([raw], { type: 'text/plain' }
))
}

View File

@@ -0,0 +1,220 @@
import DOMPurify from 'dompurify';
// 1x1 transparent GIF, substituted for blocked remote images.
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBTAA7';
// Attributes whose value the browser resolves into a request.
const URL_ATTRIBUTES = new Set([
'src', 'srcset', 'imagesrcset', 'href', 'xlink:href',
'poster', 'background', 'data', 'action', 'formaction',
]);
const NAVIGATION_HREF_ELEMENTS = new Set(['A', 'AREA']);
// Elements that fetch on their own, redirect the frame, or re-base every
// relative URL in the document. None of them belong in a mail body, and
// <base> in particular would turn the relative paths we deliberately keep
// into requests to whatever host it names.
const FORBIDDEN = [
'base', 'meta', 'script', 'link', 'iframe', 'frame', 'frameset',
'object', 'embed', 'noscript', 'template', 'portal',
];
// DOMPurify's default scheme list has no blob:, but email-parser.js rewrites
// cid: attachments into blob: URLs -- without this every inline image would be
// stripped along with the trackers.
const ALLOWED_URI_REGEXP =
/^(?:(?:https?|mailto|tel|callto|sms|cid|xmpp|blob|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
// CSS constructs that can load a resource. @import is listed because it also
// accepts a bare string -- `@import "https://..."` fetches without any url().
const CSS_FETCHES = /url\(|image-set|image\(|cross-fade|element\(|@import/i;
// A url(...) token or a bare string, either of which can name a resource.
const CSS_TOKEN = /url\(\s*(['"]?)([^'")]*)\1\s*\)|(['"])([^'"]*)\3/g;
const CSS_ESCAPE = /\\([0-9a-f]{1,6})[ \t\r\n\f]?|\\([^\r\n\f0-9a-f])/gi;
const CSS_ESCAPED_IDENTIFIER =
/@?(?:[-_a-z0-9]|\\(?:[0-9a-f]{1,6}[ \t\r\n\f]?|[^\r\n\f0-9a-f]))+/gi;
const CSS_FETCH_IDENTIFIERS = new Set([
'url', 'image-set', '-webkit-image-set', 'image', 'cross-fade',
'-webkit-cross-fade', 'element', '-moz-element', '@import',
]);
function decodeCssEscapes(value) {
return value.replace(CSS_ESCAPE, (_match, hex, escaped) => {
if (!hex) {
return escaped;
}
const codePoint = Number.parseInt(hex, 16);
if (codePoint === 0 || codePoint > 0x10FFFF ||
(codePoint >= 0xD800 && codePoint <= 0xDFFF)) {
return '\uFFFD';
}
return String.fromCodePoint(codePoint);
});
}
function normalizeCssFetchIdentifiers(value) {
return value.replace(CSS_ESCAPED_IDENTIFIER, (identifier) => {
if (!identifier.includes('\\')) {
return identifier;
}
const decoded = decodeCssEscapes(identifier);
return CSS_FETCH_IDENTIFIERS.has(decoded.toLowerCase()) ? decoded : identifier;
});
}
/**
* Whether a URL can be *proven* to stay off the network.
*
* This is deliberately an allowlist. Asking "does this look remote?" means
* enumerating every way a scheme can be disguised -- backslashes, tabs and
* control characters the URL parser strips, CSS escapes, schemes with no
* slashes -- and losing to the first one not thought of. Asking "can I prove
* this is local?" fails closed instead: anything unrecognised is blocked.
*
* Relative paths qualify only because <base> is removed above, so they can
* resolve to nothing but our own origin.
*/
function provablyLocal(value) {
const url = String(value ?? '').trim();
if (url === '') {
return true;
}
if (/^(?:cid:|blob:|data:image\/)/i.test(url)) {
return true;
}
// Relative: starts with a path/query/fragment marker and is not the
// protocol-relative "//host" form (or its backslash equivalent).
if (/^[/.?#]/.test(url)) {
return !/^[/\\]{2}/.test(url);
}
// No scheme separator and no backslash at all -- a bare relative filename.
return !/[:\\]/.test(url);
}
/** srcset holds several candidates; every one of them has to be local. */
function srcsetIsLocal(value) {
return String(value ?? '')
.split(',')
.map((candidate) => candidate.trim().split(/\s+/)[0])
.filter(Boolean)
.every(provablyLocal);
}
/**
* Replaces every resource reference in a chunk of CSS that cannot be proven
* local. Tokens are substituted in place rather than whole declarations
* dropped, so the surrounding rule structure survives.
*/
function blockCssUrls(cssText, onBlocked) {
const normalizedCssText = normalizeCssFetchIdentifiers(cssText);
if (!CSS_FETCHES.test(normalizedCssText)) {
return cssText;
}
return normalizedCssText.replace(CSS_TOKEN, (match, urlQuote, urlValue, strQuote, strValue) => {
const isUrlToken = urlValue !== undefined;
const token = isUrlToken ? urlValue : strValue;
if (provablyLocal(token)) {
return match;
}
onBlocked();
return isUrlToken
? `url(${urlQuote}${TRANSPARENT_PIXEL}${urlQuote})`
: `${strQuote}${TRANSPARENT_PIXEL}${strQuote}`;
});
}
let purifier = null;
let blockedCount = 0;
/**
* An isolated DOMPurify instance. The hooks below must not reach the shared
* singleton, which mail-actions.js uses when building replies -- quoting a
* mail should keep its images.
*/
function getPurifier() {
if (purifier) {
return purifier;
}
purifier = DOMPurify(window);
purifier.addHook('uponSanitizeAttribute', (node, data) => {
if (!URL_ATTRIBUTES.has(data.attrName)) {
return;
}
if (data.attrName === 'href' && NAVIGATION_HREF_ELEMENTS.has(node.tagName)) {
return;
}
const isSrcset = data.attrName === 'srcset' || data.attrName === 'imagesrcset';
if (isSrcset ? srcsetIsLocal(data.attrValue) : provablyLocal(data.attrValue)) {
return;
}
blockedCount += 1;
data.keepAttr = false;
// The original URL is dropped rather than parked in a data-* attribute:
// "the cleaned body contains no remote URL at all" is an invariant that
// can be asserted directly, and restoring images re-renders from the
// untouched source anyway. <img> keeps a placeholder so layout holds.
if (data.attrName === 'src' && node.tagName === 'IMG') {
node.setAttribute('src', TRANSPARENT_PIXEL);
}
});
purifier.addHook('afterSanitizeElements', (node) => {
if (node.tagName === 'STYLE') {
const cleaned = blockCssUrls(node.textContent || '', () => { blockedCount += 1; });
if (cleaned !== node.textContent) {
node.textContent = cleaned;
}
}
});
purifier.addHook('afterSanitizeAttributes', (node) => {
const style = node.getAttribute && node.getAttribute('style');
if (!style) {
return;
}
const cleaned = blockCssUrls(style, () => { blockedCount += 1; });
if (cleaned !== style) {
node.setAttribute('style', cleaned);
}
});
return purifier;
}
/**
* Strips everything in an email body that would make the browser fetch from a
* third party, so opening the mail cannot be used to confirm it was read.
*
* Sanitising is delegated to DOMPurify rather than hand-rolled: the hard part
* is not enumerating attributes but surviving the parser, and mutation-XSS is
* DOMPurify's specialty. A hand-written pass over a DOMParser tree missed, for
* one example, that <noscript> is parsed as markup where scripting is off and
* as raw text where it is on -- so a `</noscript>` smuggled into an attribute
* value reopens the document at render time and revives an <img> that the
* cleaner never saw.
*
* @param {string} html
* @returns {{ html: string, blocked: number }} blocked counts the references removed
*/
export function blockRemoteContent(html) {
if (!html || typeof html !== 'string') {
return { html: html || '', blocked: 0 };
}
blockedCount = 0;
const sanitised = getPurifier().sanitize(html, {
FORBID_TAGS: FORBIDDEN,
// Mail layout leans on <style> blocks, so they are kept and their
// url() references filtered instead of dropping the tag wholesale.
// FORCE_BODY is what makes a leading <style> survive: without it the
// parser hoists it into <head> and DOMPurify discards it.
ADD_TAGS: ['style'],
FORCE_BODY: true,
ALLOWED_URI_REGEXP,
ALLOW_DATA_ATTR: true,
});
return { html: sanitised, blocked: blockedCount };
}

View File

@@ -0,0 +1,5 @@
import DOMPurify from 'dompurify';
export const sanitizeHtml = (html) => {
return DOMPurify.sanitize(typeof html === 'string' ? html : '');
};

View File

@@ -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">
@@ -120,7 +120,7 @@ onMounted(async () => {
</n-modal>
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="quick-setup-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
@@ -136,7 +136,7 @@ onMounted(async () => {
</n-tabs>
</n-tab-pane>
<n-tab-pane name="account" :tab="t('account')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="account" :tab="t('account')">
<Account />
</n-tab-pane>
@@ -161,7 +161,7 @@ onMounted(async () => {
</n-tabs>
</n-tab-pane>
<n-tab-pane name="user" :tab="t('user')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="user-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
@@ -177,7 +177,7 @@ onMounted(async () => {
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="mails-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
@@ -202,7 +202,7 @@ onMounted(async () => {
<Statistics />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<n-tabs type="bar" justify-content="center" animated>
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -4,6 +4,7 @@ import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../../store'
import { api } from '../../api'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const {
loading, openSettings,
@@ -13,7 +14,8 @@ const message = useMessage()
const { t } = useScopedI18n('views.admin.CreateAccount')
const enablePrefix = ref(true)
const enableRandomSubdomain = ref(false)
const subdomainMode = ref("normal")
const customSubdomain = ref("")
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
@@ -30,7 +32,7 @@ const canUseRandomSubdomain = computed(() => {
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false
subdomainMode.value = "normal"
}
})
@@ -40,13 +42,16 @@ const newEmail = async () => {
return
}
try {
const domain = subdomainMode.value === "custom"
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value
const res = await api.fetch(`/admin/new_address`, {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
enableRandomSubdomain: enableRandomSubdomain.value,
enableRandomSubdomain: subdomainMode.value === "random",
name: emailName.value,
domain: emailDomain.value,
domain,
})
})
result.value = res["jwt"];
@@ -59,10 +64,6 @@ const newEmail = async () => {
}
}
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${result.value}`
}
onMounted(async () => {
if (openSettings.prefix) {
enablePrefix.value = true
@@ -73,27 +74,8 @@ onMounted(async () => {
<template>
<div class="center">
<n-modal v-model:show="showReultModal" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ result }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ createdAddress }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<AddressCredentialModal v-model:show="showReultModal" :address="createdAddress" :jwt="result"
:address-password="addressPassword" />
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-form-item-row v-if="openSettings.prefix" :label="t('enablePrefix')">
<n-switch v-model:value="enablePrefix" :round="false" />
@@ -110,14 +92,25 @@ onMounted(async () => {
</n-input-group>
</n-form-item-row>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<div style="width: 100%;">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</div>
</n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading">
<n-button @click="newEmail" type="primary" block :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
{{ t('creatNewEmail') }}
</n-button>
</n-card>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed, ref, onMounted } from 'vue';
import { useScopedI18n } from '@/i18n/app'
import { CleaningServicesFilled } from '@vicons/material'
@@ -7,24 +7,95 @@ import { api } from '../../api'
import { init } from 'vooks/lib/on-fonts-ready';
const message = useMessage()
const D1_STORAGE_PLAN_CONFIG_KEY = 'd1_storage_plan'
const dbVersionData = ref({
need_initialization: false,
need_migration: false,
current_db_version: '',
code_db_version: ''
code_db_version: '',
database_size: null
})
const selectedPlan = ref(null)
const savedPlan = ref(null)
const savingPlan = ref(false)
const planOptions = computed(() => [
{
label: t('free_plan'),
value: 'free',
databaseLimit: 500 * 1024 ** 2
},
{
label: t('paid_plan'),
value: 'paid',
databaseLimit: 10 * 1024 ** 3
}
])
const selectedPlanDetails = computed(() => (
planOptions.value.find((plan) => plan.value === selectedPlan.value)
))
const storagePercentage = computed(() => {
if (!selectedPlanDetails.value || dbVersionData.value.database_size === null) return 0
return dbVersionData.value.database_size / selectedPlanDetails.value.databaseLimit * 100
})
const progressPercentage = computed(() => Math.min(storagePercentage.value, 100))
const progressStatus = computed(() => {
if (storagePercentage.value >= 90) return 'error'
if (storagePercentage.value >= 75) return 'warning'
return 'success'
})
const { t } = useScopedI18n('views.admin.DatabaseManager')
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined) return t('unavailable')
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / 1024 ** unitIndex
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIndex]}`
}
const fetchData = async () => {
try {
const res = await api.fetch('/admin/db_version');
if (res) Object.assign(dbVersionData.value, res);
const [versionRes, configRes] = await Promise.all([
api.fetch('/admin/db_version'),
api.fetch(`/admin/config/${D1_STORAGE_PLAN_CONFIG_KEY}`)
]);
if (versionRes) Object.assign(dbVersionData.value, versionRes);
const configuredPlan = configRes?.value
if (planOptions.value.some((plan) => plan.value === configuredPlan)) {
selectedPlan.value = configuredPlan
savedPlan.value = configuredPlan
}
} catch (error) {
message.error(error.message || "error");
}
}
const savePlan = async (plan) => {
savingPlan.value = true
try {
await api.fetch('/admin/config', {
method: 'POST',
body: { key: D1_STORAGE_PLAN_CONFIG_KEY, value: plan }
})
savedPlan.value = plan
message.success(t('planSaved'))
} catch (error) {
selectedPlan.value = savedPlan.value
message.error(error.message || "error")
} finally {
savingPlan.value = false
}
}
const initialization = async () => {
try {
await api.fetch('/admin/db_initialize', {
@@ -77,6 +148,58 @@ onMounted(async () => {
</span>
</n-alert>
<div class="storage-panel">
<div class="storage-heading">
<div>
<h3>{{ t('storage_title') }}</h3>
<p>{{ t('storage_description') }}</p>
</div>
<div class="plan-select">
<span>{{ t('plan') }}</span>
<n-select
v-model:value="selectedPlan"
:options="planOptions"
:placeholder="t('plan_placeholder')"
:disabled="dbVersionData.need_initialization"
:loading="savingPlan"
@update:value="savePlan"
/>
</div>
</div>
<n-grid cols="1 s:2" responsive="screen" :x-gap="12" :y-gap="12">
<n-grid-item>
<div class="storage-stat">
<span>{{ t('current_database_size') }}</span>
<strong>{{ formatBytes(dbVersionData.database_size) }}</strong>
</div>
</n-grid-item>
<n-grid-item>
<div class="storage-stat">
<span>{{ t('single_database_limit') }}</span>
<strong>{{ selectedPlanDetails ? formatBytes(selectedPlanDetails.databaseLimit) : '—' }}</strong>
</div>
</n-grid-item>
</n-grid>
<div v-if="selectedPlanDetails" class="storage-progress">
<div class="storage-progress-label">
<span>{{ t('storage_usage') }}</span>
<span>{{ storagePercentage.toFixed(2) }}%</span>
</div>
<n-progress
type="line"
:percentage="progressPercentage"
:status="progressStatus"
:show-indicator="false"
/>
</div>
<n-alert class="storage-tip" type="default" :show-icon="false" :bordered="false">
{{ t('storage_tip') }}
</n-alert>
</div>
</n-card>
</div>
</template>
@@ -100,4 +223,88 @@ onMounted(async () => {
.n-button {
margin-top: 10px;
}
.storage-panel {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--n-border-color);
text-align: left;
}
.storage-heading {
display: flex;
gap: 24px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
}
.storage-heading h3,
.storage-heading p {
margin: 0;
}
.storage-heading p {
margin-top: 4px;
color: var(--n-text-color-3);
}
.plan-select {
width: 220px;
}
.plan-select > span {
display: block;
margin-bottom: 6px;
color: var(--n-text-color-2);
}
.storage-stat {
display: flex;
min-height: 72px;
padding: 14px;
box-sizing: border-box;
flex-direction: column;
justify-content: space-between;
border: 1px solid var(--n-border-color);
border-radius: var(--n-border-radius);
}
.storage-stat span {
color: var(--n-text-color-3);
}
.storage-stat strong {
margin-top: 8px;
font-size: 18px;
color: var(--n-text-color);
}
.storage-progress {
margin-top: 16px;
}
.storage-progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
color: var(--n-text-color-2);
}
.storage-tip {
margin-top: 16px;
margin-bottom: 0;
}
@media (max-width: 640px) {
.storage-heading {
align-items: stretch;
flex-direction: column;
gap: 12px;
}
.plan-select {
width: 100%;
}
}
</style>

View File

@@ -304,7 +304,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="createUser" />
</n-form-item-row>
</n-form>
<template #action>
@@ -315,7 +316,8 @@ onMounted(async () => {
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="newResetPassword" type="password" show-password-on="click" />
<n-input v-model:value="newResetPassword" type="password" show-password-on="click"
@keyup.enter="resetPassword" />
</n-form-item-row>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="primary">

View File

@@ -1,13 +1,16 @@
<script setup>
import { computed } from 'vue'
import { GithubAlt, Discord, Telegram } from '@vicons/fa'
import { useGlobalState } from '../../store'
import { sanitizeHtml } from '../../utils/sanitize-html'
const { announcement } = useGlobalState()
const safeAnnouncement = computed(() => sanitizeHtml(announcement.value))
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded>
<div v-html="announcement"></div>
<div v-html="safeAnnouncement"></div>
<n-button tag="a" target="_blank" href="https://github.com/dreamhunter2333/cloudflare_temp_email">
<template #icon>
<n-icon :component="GithubAlt" />

View File

@@ -11,8 +11,8 @@ const props = defineProps({
})
const {
mailboxSplitSize, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex
mailboxSplitSize, mailListView, mailListPreviewLineClamp, useIframeShowMail, preferShowTextMail, configAutoRefreshInterval,
globalTabplacement, useSideMargin, useUTCDate, useSimpleIndex, autoLoadRemoteImages
} = useGlobalState()
const isMobile = useIsMobile()
@@ -23,12 +23,26 @@ const { t } = useScopedI18n('views.common.Appearance')
<div class="center">
<n-card :bordered="false" embedded>
<n-form-item-row v-if="!isMobile" :label="t('mailboxSplitSize')">
<n-slider v-model:value="mailboxSplitSize" :min="0.25" :max="0.75" :step="0.01" :marks="{
<n-slider v-model:value="mailboxSplitSize" :min="0" :max="0.75" :step="0.01" :marks="{
0: '0',
0.25: '0.25',
0.5: '0.5',
0.75: '0.75'
}" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('mailListView')">
<n-switch v-model:value="mailListView" :round="false" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('mailListPreviewLineClamp')">
<n-slider v-model:value="mailListPreviewLineClamp" :min="0" :max="5" :step="1" :marks="{
0: t('off'),
1: '1',
2: '2',
3: '3',
4: '4',
5: '5'
}" />
</n-form-item-row>
<n-form-item-row :label="t('autoRefreshInterval')">
<n-slider v-model:value="configAutoRefreshInterval" :min="30" :max="300" :step="1" :marks="{
60: '60', 120: '120', 180: '180', 240: '240'
@@ -46,6 +60,9 @@ const { t } = useScopedI18n('views.common.Appearance')
<n-form-item-row :label="t('useUTCDate')">
<n-switch v-model:value="useUTCDate" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('autoLoadRemoteImages')">
<n-switch v-model:value="autoLoadRemoteImages" :round="false" />
</n-form-item-row>
<n-form-item-row v-if="!isMobile" :label="t('useSideMargin')">
<n-switch v-model:value="useSideMargin" :round="false" />
</n-form-item-row>

View File

@@ -48,7 +48,8 @@ const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const enableRandomSubdomain = ref(false)
const subdomainMode = ref("normal")
const customSubdomain = ref("")
const loginCfToken = ref("")
const loginTurnstileRef = ref(null)
const loginMethod = ref('credential') // 'credential' or 'password'
@@ -167,11 +168,14 @@ const newEmail = async () => {
try {
// If custom names are disabled, send empty name to trigger backend auto-generation
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
const domainToSend = subdomainMode.value === "custom"
? `${customSubdomain.value.trim()}.${emailDomain.value}`
: emailDomain.value;
const res = await props.newAddressPath(
nameToSend,
emailDomain.value,
domainToSend,
cfToken.value,
enableRandomSubdomain.value
subdomainMode.value === "random"
);
jwt.value = res["jwt"];
addressPassword.value = res["password"] || '';
@@ -206,7 +210,7 @@ const canUseRandomSubdomain = computed(() => {
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false;
subdomainMode.value = "normal";
}
});
@@ -260,7 +264,8 @@ onMounted(async () => {
<n-input v-model:value="loginAddress" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="loginPassword" type="password" show-password-on="click" />
<n-input v-model:value="loginPassword" type="password" show-password-on="click"
@keyup.enter="login" />
</n-form-item-row>
</div>
@@ -320,15 +325,26 @@ onMounted(async () => {
:options="domainsOptions" />
</n-input-group>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<div style="width: 100%;">
<n-radio-group v-model:value="subdomainMode">
<n-space vertical>
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
</n-space>
</n-radio-group>
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
<n-input v-model:value="customSubdomain" />
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
</n-input-group>
</div>
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading"
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
<template #icon>
<n-icon :component="NewLabelOutlined" />
</template>

View File

@@ -92,7 +92,7 @@ const changePassword = async () => {
<template>
<div class="center" v-if="settings.address">
<n-card :bordered="false" embedded>
<n-card :bordered="false" embedded class="account-card">
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
{{ t('showAddressCredential') }}
</n-button>
@@ -110,11 +110,13 @@ const changePassword = async () => {
<n-button @click="showLogout = true" secondary block strong>
{{ t('logout') }}
</n-button>
<n-divider v-if="openSettings.enableUserDeleteEmail" />
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
block strong>
{{ t('deleteAccount') }}
</n-button>
</n-card>
<n-modal v-model:show="showLogout" preset="dialog" :title="t('logout')">
<p>{{ t('logoutConfirm') }}</p>
<template #action>
@@ -151,10 +153,12 @@ const changePassword = async () => {
<n-modal v-model:show="showChangePassword" preset="dialog" :title="t('changePassword')">
<n-form :model="{ newPassword, confirmPassword }">
<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="changePassword" />
</n-form-item>
<n-form-item :label="t('confirmPassword')">
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click" />
<n-input v-model:value="confirmPassword" type="password" placeholder="" show-password-on="click"
@keyup.enter="changePassword" />
</n-form-item>
</n-form>
<template #action>
@@ -172,13 +176,13 @@ const changePassword = async () => {
justify-content: center;
}
.n-card {
.account-card {
max-width: 800px;
text-align: left;
}
.n-button {
margin-top: 10px;
margin-top: 14px;
}
</style>

View File

@@ -12,6 +12,7 @@ import LocalAddress from './LocalAddress.vue'
import AddressManagement from '../user/AddressManagement.vue'
import { getRouterPathWithLang } from '../../utils'
import AddressSelect from '../../components/AddressSelect.vue'
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
const router = useRouter()
@@ -24,10 +25,6 @@ const { locale, t } = useScopedI18n('views.index.AddressBar')
const showAddressManage = ref(false)
const getUrlWithJwt = () => {
return `${window.location.origin}/?jwt=${jwt.value}`
}
const onUserLogin = async () => {
await router.push(getRouterPathWithLang("/user", locale.value))
}
@@ -78,27 +75,8 @@ onMounted(async () => {
</n-button>
</n-card>
</div>
<n-modal v-model:show="showAddressCredential" preset="dialog" :title="t('addressCredential')">
<span>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card embedded>
<b>{{ jwt }}</b>
</n-card>
<n-card embedded v-if="addressPassword">
<p><b>{{ settings.address }}</b></p>
<p>{{ t('addressPassword') }}: <b>{{ addressPassword }}</b></p>
</n-card>
<n-card embedded>
<n-collapse>
<n-collapse-item :title='t("linkWithAddressCredential")'>
<n-card embedded>
<b>{{ getUrlWithJwt() }}</b>
</n-card>
</n-collapse-item>
</n-collapse>
</n-card>
</n-modal>
<AddressCredentialModal v-model:show="showAddressCredential" :address="settings.address" :jwt="jwt"
:address-password="addressPassword" />
<n-modal v-model:show="showAddressManage" preset="card" :title="t('addressManage')"
style="width: 720px;">
<TelegramAddress v-if="isTelegram" />

View File

@@ -116,7 +116,7 @@ const requestAccess = async () => {
body: JSON.stringify({})
}
)
message.success(t("success"))
message.success(t("requestSuccess"))
await api.getSettings();
} catch (error) {
message.error(error.message || "error");
@@ -161,7 +161,7 @@ onMounted(async () => {
<n-card :bordered="false" embedded>
<div v-if="!settings.send_balance || settings.send_balance <= 0">
<n-alert type="warning" :show-icon="false" :bordered="false">
{{ t('requestAccessTip') }}
{{ t('requestAccessTip', { address: settings.address }) }}
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
}}</n-button>
</n-alert>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, h, onMounted } from 'vue';
import { ref, h, onMounted, watch } from 'vue';
import { useScopedI18n } from '@/i18n/app'
import { useRouter } from 'vue-router';
import { NBadge, NPopconfirm, NButton } from 'naive-ui'
@@ -17,6 +17,9 @@ const router = useRouter()
const { locale, t } = useScopedI18n('views.user.AddressManagement')
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showTranferAddress = ref(false)
const currentAddress = ref("")
const currentAddressId = ref(0)
@@ -46,7 +49,11 @@ const unbindAddress = async (address_id) => {
body: JSON.stringify({ address_id })
});
message.success(t('unbindAddress') + " " + t('success'));
await fetchData();
if (page.value === 1) {
await fetchData();
} else {
page.value = 1;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
@@ -71,7 +78,11 @@ const transferAddress = async () => {
})
});
message.success(t('transferAddress') + " " + t('success'));
await fetchData();
if (page.value === 1) {
await fetchData();
} else {
page.value = 1;
}
showTranferAddress.value = false;
currentAddressId.value = 0;
currentAddress.value = "";
@@ -84,10 +95,17 @@ const transferAddress = async () => {
const fetchData = async () => {
try {
const { results } = await api.fetch(
`/user_api/bind_address`
const params = new URLSearchParams({
limit: String(pageSize.value),
offset: String((page.value - 1) * pageSize.value),
});
const { results, count: addressCount } = await api.fetch(
`/user_api/bind_address?${params.toString()}`
);
data.value = results;
if (page.value === 1) {
count.value = addressCount;
}
} catch (error) {
console.log(error)
message.error(error.message || "error");
@@ -178,6 +196,10 @@ const columns = [
onMounted(async () => {
await fetchData()
})
watch([page, pageSize], async () => {
await fetchData();
})
</script>
<template>
@@ -197,6 +219,12 @@ onMounted(async () => {
<n-tabs type="segment">
<n-tab-pane name="address" :tab="t('address')">
<div class="address-table-scroll">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
<n-data-table :columns="columns" :data="data" :bordered="false" embedded />
</div>
</n-tab-pane>
@@ -216,4 +244,9 @@ onMounted(async () => {
max-width: 100%;
overflow-x: auto;
}
.n-pagination {
margin-top: 10px;
margin-bottom: 10px;
}
</style>

View File

@@ -187,7 +187,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailLogin" />
</n-form-item-row>
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
<n-button @click="emailLogin" type="primary" block secondary strong>
@@ -218,7 +219,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailSignup" />
</n-form-item-row>
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
@@ -244,7 +246,8 @@ onMounted(async () => {
<n-input v-model:value="user.email" />
</n-form-item-row>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
<n-input v-model:value="user.password" type="password" show-password-on="click"
@keyup.enter="emailSignup" />
</n-form-item-row>
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
<n-form-item-row :label="t('verifyCode')" required>

View File

@@ -32,7 +32,7 @@ const fetchMailData = async (limit, offset) => {
const fetchAddresData = async () => {
try {
const { results } = await api.fetch(
`/user_api/bind_address`
`/user_api/bind_address?limit=100&offset=0`
);
addressFilterOptions.value = results.map((item) => {
return {

View File

@@ -1,6 +1,6 @@
{
"name": "temp-email-pages",
"version": "1.8.0",
"version": "1.11.1",
"description": "",
"main": "index.js",
"scripts": {
@@ -11,7 +11,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"wrangler": "^4.83.0"
"wrangler": "^4.124.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

View File

@@ -1,3 +1,5 @@
set -euo pipefail
cd frontend/
pnpm up
pnpm add -D wrangler@latest
@@ -17,3 +19,18 @@ cd vitepress-docs/
pnpm up --latest
pnpm add -D wrangler@latest
cd ..
cd e2e/ || exit 1
npx --yes npm-check-updates@23.0.0 --upgrade
npm install
PLAYWRIGHT_VERSION="$(node -p "require('./package.json').devDependencies['@playwright/test']")"
EXPECTED_IMAGE="mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble"
if ! awk -v expected="${EXPECTED_IMAGE}" \
'$1 == "FROM" && $2 == expected { found=1 } END { exit !found }' \
Dockerfile.e2e; then
echo "Dockerfile.e2e must use Playwright ${PLAYWRIGHT_VERSION}" >&2
exit 1
fi
cd ..

View File

@@ -7,3 +7,4 @@ imap_port=11143
# imap_tls_key=/path/to/key.pem
# imap_cache_size=500
# imap_http_timeout=30.0
# imap_flag_db_path=data/imap_flags.db

View File

@@ -21,6 +21,7 @@ class Settings(BaseSettings):
imap_tls_key: str = ""
imap_cache_size: int = 500
imap_http_timeout: float = 30.0
imap_flag_db_path: str = "data/imap_flags.db"
model_config = SettingsConfigDict(env_file=".env")

View File

@@ -12,3 +12,6 @@ services:
- proxy_url=https://temp-email-api.xxx.xxx
- port=8025
- imap_port=11143
volumes:
# Persists IMAP flags (e.g. \Seen) across container restarts
- ./data:/app/data

View File

@@ -0,0 +1,178 @@
import json
import os
import sqlite3
import threading
def _encode_flags(flags: set[str]) -> str:
# JSON, not a comma-joined string: commas are legal inside IMAP keywords,
# so "foo,bar" as a single keyword must not be split into two on read.
return json.dumps(sorted(flags))
def _decode_flags(text: str) -> set[str]:
if not text:
return set()
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return {str(flag) for flag in parsed}
except ValueError:
pass
# Rows written before the switch to JSON were comma-joined.
return set(text.split(","))
class FlagStore:
"""Persists IMAP message flags (e.g. \\Seen) to a local SQLite file.
SimpleMailbox previously kept flags only in an in-memory dict that was
recreated from scratch for every IMAP connection (see SimpleRealm.requestAvatar
in imap_server.py), so a client's STORE command (e.g. marking a message as
read) was silently lost as soon as the session ended. This store gives
flags a durable home keyed by (address, mailbox, uid) so they survive
reconnects.
"""
_UPSERT_SQL = """
INSERT INTO imap_flags (address, mailbox, uid, flags)
VALUES (?, ?, ?, ?)
ON CONFLICT(address, mailbox, uid) DO UPDATE SET flags = excluded.flags
"""
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
self._init_db()
def _connect(self) -> sqlite3.Connection:
# isolation_level=None: transactions are managed explicitly below, so
# sqlite3 must not inject its own implicit BEGIN.
return sqlite3.connect(self._db_path, timeout=10, isolation_level=None)
def _init_db(self):
dirname = os.path.dirname(self._db_path)
if dirname:
os.makedirs(dirname, exist_ok=True)
with self._lock:
conn = self._connect()
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS imap_flags (
address TEXT NOT NULL,
mailbox TEXT NOT NULL,
uid INTEGER NOT NULL,
flags TEXT NOT NULL,
PRIMARY KEY (address, mailbox, uid)
)
"""
)
finally:
conn.close()
def get_all(self, address: str, mailbox: str) -> dict[int, set[str]]:
"""Return {uid: flags} for every UID with stored flags in a mailbox."""
with self._lock:
conn = self._connect()
try:
rows = conn.execute(
"SELECT uid, flags FROM imap_flags WHERE address = ? AND mailbox = ?",
(address, mailbox),
).fetchall()
finally:
conn.close()
return {uid: _decode_flags(flags) for uid, flags in rows}
def update_flags(
self,
address: str,
mailbox: str,
uids: list[int],
flags: set[str],
mode: int,
) -> dict[int, set[str]]:
"""Atomically apply an IMAP STORE to the given UIDs.
mode follows twisted's convention: 1 adds (+FLAGS), -1 removes
(-FLAGS), 0 replaces (FLAGS). Returns {uid: resulting flags}.
The read-modify-write runs inside one BEGIN IMMEDIATE transaction:
two concurrent sessions that loaded the same stale in-memory flags
cannot overwrite each other's STORE, because each session's update is
recomputed from the row current at its own commit.
"""
if not uids:
return {}
flags = set(flags)
result: dict[int, set[str]] = {}
with self._lock:
conn = self._connect()
try:
conn.execute("BEGIN IMMEDIATE")
try:
placeholders = ",".join("?" for _ in uids)
rows = conn.execute(
"SELECT uid, flags FROM imap_flags"
" WHERE address = ? AND mailbox = ?"
f" AND uid IN ({placeholders})",
(address, mailbox, *uids),
).fetchall()
current = {uid: _decode_flags(text) for uid, text in rows}
upserts = []
for uid in uids:
old = current.get(uid, set())
if mode == 1:
new = old | flags
elif mode == -1:
new = old - flags
else:
new = set(flags)
result[uid] = new
upserts.append((address, mailbox, uid, _encode_flags(new)))
conn.executemany(self._UPSERT_SQL, upserts)
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
return result
def set_flags_bulk(
self, address: str, mailbox: str, uid_flags: dict[int, set[str]]
) -> None:
"""Upsert flags for multiple UIDs in a single transaction."""
if not uid_flags:
return
rows = [
(address, mailbox, uid, _encode_flags(flags))
for uid, flags in uid_flags.items()
]
with self._lock:
conn = self._connect()
try:
conn.execute("BEGIN IMMEDIATE")
try:
conn.executemany(self._UPSERT_SQL, rows)
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
_default_store: FlagStore | None = None
_default_store_lock = threading.Lock()
def get_flag_store() -> FlagStore:
"""Return the process-wide FlagStore, created lazily from settings."""
global _default_store
if _default_store is None:
with _default_store_lock:
if _default_store is None:
from config import settings
_default_store = FlagStore(settings.imap_flag_db_path)
return _default_store

View File

@@ -3,11 +3,12 @@ import logging
import time
from collections import OrderedDict
from twisted.internet import defer
from twisted.internet import defer, threads
from twisted.mail import imap4
from zope.interface import implementer
from config import settings
from flag_store import FlagStore, get_flag_store
from imap_http_client import BackendClient
from imap_message import SimpleMessage
from parse_email import generate_email_model, parse_email, clean_raw_headers, fix_mojibake
@@ -51,9 +52,12 @@ class MessageCache:
@implementer(imap4.IMailboxInfo, imap4.IMailbox, imap4.ISearchableMailbox)
class SimpleMailbox:
def __init__(self, name: str, client: BackendClient):
def __init__(self, name: str, client: BackendClient, address: str,
flag_store: FlagStore = None):
self.name = name
self._client = client
self._address = address
self._flag_store = flag_store if flag_store is not None else get_flag_store()
self.listeners = []
self.addListener = self.listeners.append
self.removeListener = self.listeners.remove
@@ -76,7 +80,10 @@ class SimpleMailbox:
return 0
def getUnseenCount(self):
return 0
return sum(
1 for u in self._uid_index
if r"\Seen" not in self._flags.get(u, set())
)
def isWriteable(self):
return 1
@@ -123,6 +130,7 @@ class SimpleMailbox:
if count == 0:
self._uid_index = []
self._uid_index_built = True
self._flags = {}
return
uid_set = set()
@@ -146,6 +154,11 @@ class SimpleMailbox:
self._uid_index = sorted(uid_set)
self._uid_index_built = True
# Load persisted flags (e.g. \Seen set by a previous IMAP session) so
# STORE results survive reconnects instead of resetting every session.
self._flags = yield threads.deferToThread(
self._flag_store.get_all, self._address, self.name
)
_logger.info(
"UID index built for %s: %d UIDs, range=%s..%s",
self.name, len(self._uid_index),
@@ -254,9 +267,10 @@ class SimpleMailbox:
else:
continue
if uid_val not in self._flags:
self._flags[uid_val] = {r"\Seen"}
flags = self._flags[uid_val]
# Flags default to unseen (empty set) unless a previous
# STORE persisted them via self._flag_store; self._flags
# is refreshed from that store in _build_uid_index().
flags = self._flags.get(uid_val, set())
msg = SimpleMessage(
uid_val, email_model, flags=flags, raw=raw,
created_at=item.get("created_at"),
@@ -309,18 +323,20 @@ class SimpleMailbox:
return {}
target_uids = self._resolve_message_set(messages, uid)
if not target_uids:
return {}
# Apply the delta inside the store's own transaction instead of
# computing new flag sets from this session's in-memory copy: another
# concurrent session may have STOREd since we loaded, and basing the
# write on stale state would silently drop its change.
updated_flags = yield threads.deferToThread(
self._flag_store.update_flags,
self._address, self.name, target_uids, set(flags), mode,
)
result = {}
for u in target_uids:
current_flags = self._flags.get(u, set())
if mode == 1: # +FLAGS
current_flags = current_flags | set(flags)
elif mode == -1: # -FLAGS
current_flags = current_flags - set(flags)
elif mode == 0: # FLAGS (replace)
current_flags = set(flags)
for u, current_flags in updated_flags.items():
self._flags[u] = current_flags
seq = self._uid_to_seq(u)
if seq is not None:
@@ -328,28 +344,58 @@ class SimpleMailbox:
return result
# SEARCH keys we can answer from persisted flag state, mapped to the flag
# and whether it must be present. Anything else falls through to matching
# every message, which is how this mailbox answered every query before
# flags were persisted.
_FLAG_SEARCH_KEYS = {
"SEEN": ("\\Seen", True),
"UNSEEN": ("\\Seen", False),
"DELETED": ("\\Deleted", True),
"UNDELETED": ("\\Deleted", False),
"FLAGGED": ("\\Flagged", True),
"UNFLAGGED": ("\\Flagged", False),
"ANSWERED": ("\\Answered", True),
"UNANSWERED": ("\\Answered", False),
"DRAFT": ("\\Draft", True),
"UNDRAFT": ("\\Draft", False),
}
@defer.inlineCallbacks
def search(self, query, uid):
if not self._uid_index_built:
yield self._build_uid_index()
results = []
predicates = []
for term in query:
if isinstance(term, str) and term.upper() == "ALL":
if uid:
results = list(self._uid_index)
else:
results = list(range(1, len(self._uid_index) + 1))
break
if isinstance(term, bytes):
term = term.decode("ascii", "ignore")
if not isinstance(term, str):
continue
predicate = self._FLAG_SEARCH_KEYS.get(term.upper())
if predicate is not None:
predicates.append(predicate)
if not results:
if uid:
results = list(self._uid_index)
else:
results = list(range(1, len(self._uid_index) + 1))
matched = [
u for u in self._uid_index
if all(
(flag in self._flags.get(u, set())) is present
for flag, present in predicates
)
]
return results
_logger.info(
"SEARCH: uid=%s predicates=%d matched=%d/%d",
uid, len(predicates), len(matched), len(self._uid_index),
)
if uid:
return matched
return [
seq for seq in (self._uid_to_seq(u) for u in matched)
if seq is not None
]
def getUIDNext(self):
if self._uid_index:

View File

@@ -48,6 +48,49 @@ class SimpleIMAPServer(imap4.IMAP4Server):
return real_write_seq(data)
self.transport.writeSequence = logging_write_seq
@staticmethod
def _fetch_implies_seen(query):
"""Whether a FETCH query marks the messages it returns as read.
Per RFC 3501, ``BODY[...]`` sets ``\\Seen`` while ``BODY.PEEK[...]``
does not. ``RFC822`` and ``RFC822.TEXT`` are defined as equivalents of
``BODY[]`` / ``BODY[TEXT]`` and so also set it; ``RFC822.HEADER`` is
equivalent to ``BODY.PEEK[HEADER]`` and does not.
"""
for part in query:
part_type = getattr(part, "type", None)
if part_type == "body" and not getattr(part, "peek", False):
return True
if part_type in ("rfc822", "rfc822text"):
return True
return False
def do_FETCH(self, tag, messages, query, uid=0):
"""Set ``\\Seen`` for non-peek fetches before delivering the response.
Twisted hands the parsed query to its own response builder and never
passes it to the mailbox, so the mailbox alone cannot tell a ``BODY[]``
from a ``BODY.PEEK[]``. Flag the messages here, before the FETCH
response is generated, so the flags the client receives are current.
"""
if not query or not self._fetch_implies_seen(query):
return imap4.IMAP4Server.do_FETCH(self, tag, messages, query, uid)
def _fetch(_):
return imap4.IMAP4Server.do_FETCH(self, tag, messages, query, uid)
def _store_failed(failure):
# A failure to persist \Seen must not cost the client its mail.
_logger.warning("FETCH: could not set \\Seen: %s", failure.value)
return None
d = defer.maybeDeferred(
self.mbox.store, messages, ["\\Seen"], 1, uid
)
d.addErrback(_store_failed)
d.addCallback(_fetch)
return d
def _cbSelectWork(self, mbox, cmdName, tag):
"""Override to add UIDNEXT in SELECT response (RFC 3501)."""
if mbox is None:
@@ -83,7 +126,7 @@ class Account(imap4.MemoryAccount):
def _emptyMailbox(self, name, id):
"""Return a dummy mailbox for CREATE requests (e.g. Gmail creating Drafts)."""
_logger.debug("Accepting CREATE request for %s", name)
return SimpleMailbox(name, self._client)
return SimpleMailbox(name, self._client, self.name)
def create(self, pathspec):
"""Accept CREATE silently without actually creating mailboxes."""
@@ -111,8 +154,8 @@ class SimpleRealm:
client = BackendClient(password)
inbox = SimpleMailbox("INBOX", client)
sent = SimpleMailbox("SENT", client)
inbox = SimpleMailbox("INBOX", client, username)
sent = SimpleMailbox("SENT", client, username)
account = Account(username)
account._client = client

View File

@@ -1,6 +1,6 @@
aiosmtpd==1.4.6
pydantic-settings==2.13.1
Twisted==25.5.0
pydantic-settings==2.14.2
Twisted==26.4.0
httpx==0.28.1
pyOpenSSL==26.0.0
service-identity==24.2.0

View File

@@ -0,0 +1,288 @@
"""Regression test for GH issue #1074: IMAP STORE could not mark mail as read.
Root cause (see 1074-report.md for the full investigation):
- smtp_proxy_server/imap_server.py SimpleRealm.requestAvatar() builds a brand
new SimpleMailbox (with an empty in-memory `_flags` dict) on every IMAP
login/connection.
- smtp_proxy_server/imap_mailbox.py SimpleMailbox.store() only ever wrote
flag changes into that in-memory dict, and
SimpleMailbox._fetch_and_cache_messages() unconditionally defaulted every
message's flags to `{\\Seen}`.
- Net effect: every message always looked "already read", and a client's
STORE +FLAGS (\\Seen) command appeared to succeed but was silently
discarded the moment the IMAP session ended (e.g. Thunderbird reconnecting
to poll), which matches the reported "cannot mark mail as read".
This test drives SimpleMailbox directly (no Docker / no live worker
backend) against a FakeBackendClient, and verifies that:
1. A never-touched message starts unseen (not hardcoded \\Seen).
2. STORE +FLAGS (\\Seen) is visible to a brand new SimpleMailbox instance
pointed at the same flag_store.FlagStore, simulating a client
disconnect + reconnect.
"""
import os
import shutil
import tempfile
import unittest
from twisted.internet import defer
from twisted.mail import imap4
from twisted.python.failure import Failure
import imap_mailbox
from flag_store import FlagStore
from imap_mailbox import SimpleMailbox
from imap_server import SimpleIMAPServer
def _sync_defer_to_thread(f, *args, **kwargs):
"""Stand-in for twisted.internet.threads.deferToThread.
imap_mailbox.py offloads FlagStore's blocking sqlite calls to a thread
via deferToThread. Tests don't run a reactor to service that thread
pool, so this executes the callable inline and wraps the result as an
already-fired Deferred instead.
"""
try:
result = f(*args, **kwargs)
except Exception:
return defer.fail()
return defer.succeed(result)
def _run(d):
"""Extract the result of a Deferred that fires synchronously."""
box = []
d.addBoth(box.append)
assert box, "Deferred did not fire synchronously"
value = box[0]
if isinstance(value, Failure):
value.raiseException()
return value
class FakeBackendClient:
"""Minimal stand-in for imap_http_client.BackendClient.
Serves a fixed in-memory list of message rows so tests don't need a
live worker backend or Docker.
"""
def __init__(self, messages):
self._messages = messages
def get_message_count(self, mailbox_name):
return defer.succeed(len(self._messages))
def get_messages(self, mailbox_name, limit, offset):
page = self._messages[offset:offset + limit]
count = len(self._messages) if offset == 0 else None
return defer.succeed((page, count))
class _MailboxTestBase(unittest.TestCase):
"""Shared fixture: a temp-dir FlagStore and an inline deferToThread."""
def setUp(self):
self._tmpdir = tempfile.mkdtemp()
self._db_path = os.path.join(self._tmpdir, "flags.db")
self._orig_deferToThread = imap_mailbox.threads.deferToThread
imap_mailbox.threads.deferToThread = _sync_defer_to_thread
def tearDown(self):
imap_mailbox.threads.deferToThread = self._orig_deferToThread
shutil.rmtree(self._tmpdir, ignore_errors=True)
def _make_mailbox(self, messages, address="user@example.com"):
flag_store = FlagStore(self._db_path)
client = FakeBackendClient(messages)
return SimpleMailbox("INBOX", client, address, flag_store=flag_store)
class ImapMarkAsSeenPersistenceTest(_MailboxTestBase):
def test_new_message_starts_unseen(self):
mbox = self._make_mailbox([{"id": 1, "raw": ""}])
_run(mbox._build_uid_index())
self.assertEqual(mbox.getUnseenCount(), 1)
def test_store_seen_survives_reconnect(self):
messages = [{"id": 1, "raw": ""}, {"id": 2, "raw": ""}]
# --- Session 1: client connects, selects INBOX, sees 2 unseen ---
mbox1 = self._make_mailbox(messages)
_run(mbox1._build_uid_index())
self.assertEqual(mbox1.getUnseenCount(), 2)
# Client sends: UID STORE 1 +FLAGS (\Seen)
message_set = imap4.MessageSet(1, 1)
result = _run(mbox1.store(message_set, [r"\Seen"], mode=1, uid=True))
self.assertIn(1, [seq for seq in result])
self.assertEqual(mbox1.getUnseenCount(), 1)
# --- Session 2: client disconnects and reconnects (new SimpleMailbox
# instance, exactly like SimpleRealm.requestAvatar creates per login) ---
mbox2 = self._make_mailbox(messages)
_run(mbox2._build_uid_index())
self.assertEqual(
mbox2.getUnseenCount(), 1,
"flag set via STORE in a previous session was lost on reconnect",
)
self.assertIn(r"\Seen", mbox2._flags.get(1, set()))
self.assertNotIn(r"\Seen", mbox2._flags.get(2, set()))
def test_store_minus_flags_removes_seen(self):
messages = [{"id": 1, "raw": ""}]
mbox = self._make_mailbox(messages)
_run(mbox._build_uid_index())
message_set = imap4.MessageSet(1, 1)
_run(mbox.store(message_set, [r"\Seen"], mode=1, uid=True))
self.assertEqual(mbox.getUnseenCount(), 0)
_run(mbox.store(message_set, [r"\Seen"], mode=-1, uid=True))
self.assertEqual(mbox.getUnseenCount(), 1)
# The removal must also land in SQLite, not just this instance's
# in-memory dict: a fresh mailbox (= reconnect) must see it unseen.
mbox2 = self._make_mailbox(messages)
_run(mbox2._build_uid_index())
self.assertEqual(
mbox2.getUnseenCount(), 1,
"-FLAGS (\\Seen) was not persisted across reconnect",
)
self.assertNotIn(r"\Seen", mbox2._flags.get(1, set()))
def test_keyword_containing_comma_round_trips(self):
# Commas are legal in IMAP keywords; "foo,bar" is ONE keyword and
# must not come back from storage split into "foo" and "bar".
messages = [{"id": 1, "raw": ""}]
mbox = self._make_mailbox(messages)
_run(mbox._build_uid_index())
message_set = imap4.MessageSet(1, 1)
_run(mbox.store(message_set, ["foo,bar"], mode=1, uid=True))
mbox2 = self._make_mailbox(messages)
_run(mbox2._build_uid_index())
self.assertEqual(mbox2._flags.get(1), {"foo,bar"})
def test_concurrent_sessions_do_not_clobber_each_other(self):
# Two sessions load the same (empty) flags, then each STOREs a
# different flag on the same message. The second write must not be
# computed from its stale in-memory copy, or the first flag is lost.
messages = [{"id": 1, "raw": ""}]
mbox1 = self._make_mailbox(messages)
mbox2 = self._make_mailbox(messages)
_run(mbox1._build_uid_index())
_run(mbox2._build_uid_index())
message_set = imap4.MessageSet(1, 1)
_run(mbox1.store(message_set, [r"\Seen"], mode=1, uid=True))
_run(mbox2.store(message_set, [r"\Flagged"], mode=1, uid=True))
mbox3 = self._make_mailbox(messages)
_run(mbox3._build_uid_index())
self.assertEqual(
mbox3._flags.get(1), {r"\Seen", r"\Flagged"},
"a session's +FLAGS was lost to a concurrent session's stale write",
)
class ImapSearchFlagTest(_MailboxTestBase):
"""SEARCH must answer flag keys from persisted state.
SimpleMailbox declares ISearchableMailbox, so IMAP4Server.do_SEARCH hands
the whole query to it and never runs its own search_UNSEEN/search_SEEN
helpers. Before this, every SEARCH fell through to "return everything",
so `SEARCH UNSEEN` listed mail the user had already read.
"""
def _seen_mailbox(self):
"""Two messages, UID 1 marked \\Seen, UID 2 left unread."""
messages = [{"id": 1, "raw": ""}, {"id": 2, "raw": ""}]
mbox = self._make_mailbox(messages)
_run(mbox._build_uid_index())
_run(mbox.store(imap4.MessageSet(1, 1), [r"\Seen"], mode=1, uid=True))
return mbox
def test_search_unseen_excludes_seen_messages(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["UNSEEN"], uid=True)), [2])
def test_search_seen_returns_only_seen_messages(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["SEEN"], uid=True)), [1])
def test_search_unseen_returns_sequence_numbers_when_not_uid(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["UNSEEN"], uid=False)), [2])
def test_search_all_still_returns_everything(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["ALL"], uid=True)), [1, 2])
def test_search_accepts_bytes_terms(self):
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search([b"UNSEEN"], uid=True)), [2])
def test_unsupported_search_key_still_matches_everything(self):
# Keys this mailbox cannot evaluate keep the previous lenient
# behaviour rather than silently returning an empty result.
mbox = self._seen_mailbox()
self.assertEqual(_run(mbox.search(["SINCE"], uid=True)), [1, 2])
def test_combined_keys_are_conjunctive(self):
mbox = self._seen_mailbox()
_run(mbox.store(imap4.MessageSet(2, 2), [r"\Flagged"], mode=1, uid=True))
self.assertEqual(_run(mbox.search(["UNSEEN", "FLAGGED"], uid=True)), [2])
self.assertEqual(_run(mbox.search(["SEEN", "FLAGGED"], uid=True)), [])
class FetchImpliesSeenTest(unittest.TestCase):
"""Only non-peek body fetches may set \\Seen (RFC 3501 §6.4.5).
Twisted passes the parsed FETCH query to its own response builder and
never to the mailbox, so SimpleMailbox alone cannot distinguish BODY[]
from BODY.PEEK[]. SimpleIMAPServer.do_FETCH makes that call instead.
"""
def _implies_seen(self, command):
parser = imap4._FetchParser()
parser.parseString(command)
return SimpleIMAPServer._fetch_implies_seen(parser.result)
def test_body_sets_seen(self):
self.assertTrue(self._implies_seen(b"BODY[]"))
def test_body_section_sets_seen(self):
# BODY[HEADER] is a non-peek fetch and does set \Seen, unlike the
# RFC822.HEADER shorthand below.
self.assertTrue(self._implies_seen(b"BODY[HEADER]"))
def test_body_peek_does_not_set_seen(self):
self.assertFalse(self._implies_seen(b"BODY.PEEK[]"))
def test_rfc822_sets_seen(self):
self.assertTrue(self._implies_seen(b"RFC822"))
def test_rfc822_text_sets_seen(self):
self.assertTrue(self._implies_seen(b"RFC822.TEXT"))
def test_rfc822_header_does_not_set_seen(self):
# Defined as equivalent to BODY.PEEK[HEADER].
self.assertFalse(self._implies_seen(b"RFC822.HEADER"))
def test_metadata_only_fetch_does_not_set_seen(self):
self.assertFalse(self._implies_seen(b"FLAGS"))
self.assertFalse(self._implies_seen(b"UID"))
self.assertFalse(self._implies_seen(b"RFC822.SIZE"))
def test_mixed_query_sets_seen_if_any_part_does(self):
self.assertTrue(self._implies_seen(b"(FLAGS BODY[])"))
self.assertFalse(self._implies_seen(b"(FLAGS BODY.PEEK[])"))
if __name__ == "__main__":
unittest.main()

View File

@@ -123,6 +123,7 @@ ENABLE_AUTO_REPLY = false
# Footer text
# COPYRIGHT = "Dream Hunter"
# DISABLE_SHOW_GITHUB = true # Disable Show GitHub link
# DISABLE_SHOW_GITHUB_FOR_USER = true # Hide GitHub link for normal users only
# default send balance, if not set, it will be 0
# DEFAULT_SEND_BALANCE = 1
# the role which can send emails without limit, multiple roles can be separated by ,
@@ -140,9 +141,9 @@ ENABLE_AUTO_REPLY = false
# FRONTEND_URL = "https://xxxx.xxx"
# Enable check junk mail
# ENABLE_CHECK_JUNK_MAIL = false
# junk mail check list, if status exists and status is not pass, will be marked as junk mail
# JUNK_MAIL_CHECK_LIST = = ["spf", "dkim", "dmarc"]
# junk mail force check pass list, if no status or status is not pass, will be marked as junk mail
# junk mail check list: reject registered failure/error results; none and SPF/DKIM neutral are treated as absent
# JUNK_MAIL_CHECK_LIST = ["spf", "dkim", "dmarc"]
# junk mail force pass list: every configured method must explicitly return pass
# JUNK_MAIL_FORCE_PASS_LIST = ["spf", "dkim", "dmarc"]
# remove attachment if size exceed 2MB, mail maybe mising some information due to parsing
# REMOVE_EXCEED_SIZE_ATTACHMENT = true

View File

@@ -48,6 +48,12 @@ No additional env var is required.
Register at `https://resend.com/domains` and add DNS records according to the instructions.
> [!WARNING] DNS record proxy status on Cloudflare
> Resend domain verification CNAME records **must be set to DNS-only** (gray cloud) in
> the Cloudflare DNS dashboard. Proxied (orange cloud) records will prevent Resend from
> completing verification, and a single failed attempt can take several hours before
> Retry becomes available. See [#515](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/515).
Create an `api key` on the `API KEYS` page.
Then execute the following command to add `RESEND_TOKEN` to secrets:
@@ -144,12 +150,30 @@ cd worker
wrangler secret put SMTP_CONFIG
```
## User Accounts, Email Addresses, and Send Permission
These concepts are related, but they identify different objects:
| Concept | Description |
|---------|-------------|
| User account | Signs in to the user center and can bind and manage multiple email addresses. The user account's login email is not automatically a sending or receiving address |
| Email address | The identity that actually receives and sends mail, such as `name@example.com`. The address currently selected on the frontend home page is authenticated by its Address JWT |
| Send permission and balance | Permission and balance records in `address_sender` are managed independently **per email address**; user roles configured by `NO_LIMIT_SEND_ROLE` can bypass address balance checks |
Multiple email addresses bound to the same user account therefore do not share send permission or balance. To request permission for an address:
1. Switch to the email address that needs to send mail
2. Open the **Send Mail** page
3. Click **Request Access**
The request affects only the address selected at that time. After switching to another address, check that address's own balance and request permission separately if needed. An email address can also hold send permission without being bound to a user account.
## Send Balance Mechanism
Users need a send balance to send emails. The balance mechanism works as follows:
An email address needs its own send balance to send emails. The balance mechanism works as follows:
1. **Auto-initialize Default Quota**: When `DEFAULT_SEND_BALANCE > 0`, the system automatically initializes the default quota when the user opens the send page or calls the send-mail API for the first time
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, users can still click "Request Send Permission" in the frontend to create a pending send-access record for admins to review
2. **Manual Request**: If `DEFAULT_SEND_BALANCE = 0`, switch to the target email address in the frontend and click **Request Access** to create a pending send-access record for the current address
3. **Unlimited Sending**: The following methods can bypass balance checks:
- Add the address to the "No Limit Send Address List" in the admin console
- Configure the `NO_LIMIT_SEND_ROLE` environment variable to specify roles that can send without limits

View File

@@ -22,7 +22,9 @@ Extraction results are automatically saved to the `metadata` field in the databa
| Variable Name | Type | Description | Example |
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `ENABLE_AI_EMAIL_EXTRACT` | Text/JSON | Whether to enable AI email recognition feature | `true` |
| `AI_EXTRACT_MODEL` | Text | AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct` |
| `AI_EXTRACT_MODEL` | Text | AI model name, choose from [models supporting JSON mode](https://developers.cloudflare.com/workers-ai/features/json-mode/#supported-models) | `@cf/meta/llama-3.1-8b-instruct-fast` |
We recommend `@cf/meta/llama-3.1-8b-instruct-fast` as the default model because it supports the JSON Mode used by this feature, and Cloudflare says `-fast` variants will remain active. The cheaper `@cf/meta/llama-3.1-8b-instruct-fp8-fast` is not currently listed as a JSON Mode supported model, so it is not recommended for this feature. Cloudflare's newer recommended model `@cf/zai-org/glm-4.7-flash` is suitable for multilingual scenarios, but confirm structured JSON output support in your account/region before using it for this feature. The previous default model `@cf/meta/llama-3.1-8b-instruct` will be deprecated by Cloudflare on 2026-05-30 and is no longer recommended.
## Content Length Limit
@@ -41,6 +43,18 @@ Or add in Cloudflare Dashboard Worker settings:
- **Variable name**: `AI`
- **Type**: Workers AI
## Fallback Without a Workers AI Binding
If `ENABLE_AI_EMAIL_EXTRACT` is enabled but **no Workers AI binding is configured** (e.g. a self-hosted deployment without Workers AI), the system automatically falls back to a built-in **regex verification-code extractor**:
- Extracts **verification codes** (`auth_code`) only; links are not extracted (link extraction requires AI)
- Zero dependency, zero cost, runs locally inside the Worker
- Supports common verification-code formats in English, Chinese, Japanese and Korean
- Rejects years (e.g. `2026`) and `YYYYMMDD` dates to reduce false positives
- Results are written to `metadata` and reuse the same Telegram / webhook placeholders (`aiExtractType` is `auth_code` in this case)
When a Workers AI binding is configured, AI extraction is still preferred (recognizing both codes and links) and this fallback does not apply.
## Address Allowlist (Optional)
To control costs and resource usage, you can configure an address allowlist in the Admin console's **AI Extract Settings** page:

View File

@@ -64,6 +64,7 @@ services:
| `imap_tls_key` | empty | IMAP TLS private key file path (PEM) |
| `imap_cache_size` | `500` | Max cached messages per mailbox |
| `imap_http_timeout` | `30.0` | Backend HTTP request timeout (seconds) |
| `imap_flag_db_path` | `data/imap_flags.db` | SQLite file storing IMAP flags (e.g. `\Seen`) so `STORE` (mark as read) survives client reconnects. Mount a volume over its parent directory so it persists across container restarts |
## Enabling STARTTLS

View File

@@ -49,6 +49,27 @@ print(response.json())
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
## Admin Get Mail API
Fetch a single mail by mail ID without a mailbox JWT. Authenticate with `x-admin-auth`.
The response matches one entry returned by `/admin/mails`: gzip-compressed raw content is decompressed into `raw`, and `raw_blob` is excluded.
```python
import requests
mail_id = 1
url = f"https://<your-worker-address>/admin/mails/{mail_id}"
headers = {
"x-admin-auth": "<your-Admin-password>",
# "x-custom-auth": "<your-website-password>", # If private site password is enabled
}
response = requests.get(url, headers=headers)
print(response.json())
```
## Admin Delete Mail API
Delete a single mail by mail ID.
@@ -139,6 +160,36 @@ This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/re
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
:::
### Bound Address List
`GET /user_api/bind_address` uses server-side pagination and accepts these query parameters:
Requests without pagination parameters return the default first page. Fetching all bound addresses in one request is not supported.
| Parameter | Default | Description |
| --- | --- | --- |
| `limit` | `20` | Page size, from 1 to 100 |
| `offset` | `0` | Pagination offset |
The `results` array contains only the current page. The total is queried only when `offset=0`; later pages return `count: 0`, so clients should retain the total from the first page.
```python
import requests
url = "https://<your-worker-address>/user_api/bind_address"
headers = {
"x-user-token": "<your-user-JWT-token>",
}
querystring = {
"limit": "20",
"offset": "0",
}
response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
### User Mail List
Supports `address` filter
```python

View File

@@ -27,7 +27,7 @@ RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
RANDOM_SUBDOMAIN_LENGTH = 8
```
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow optional random second-level subdomains
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow random or manually entered subdomains
- `RANDOM_SUBDOMAIN_LENGTH`: random string length, range `1-63`, default `8`
The create-address APIs only generate a random subdomain when the request explicitly passes
@@ -47,19 +47,44 @@ the request body:
If you want to create an address under a specific subdomain such as `team.abc.com`, do not pass
`enableRandomSubdomain: true`; use the direct-subdomain flow below instead.
For base domains in `RANDOM_SUBDOMAIN_DOMAINS`, the web and admin pages offer **Normal Domain**,
**Use Random Subdomain**, and **Use Custom Subdomain** as single-choice modes. In custom mode,
enter only `team`; the frontend combines it as `team.abc.com`.
> [!NOTE]
> This feature only appends a random second-level subdomain when the mailbox is created.
>
> There is currently no backend switch that globally forces random subdomains; API calls that do
> not pass `enableRandomSubdomain: true` will not randomize automatically.
> [!IMPORTANT] A wildcard MX DNS record is required for random subdomains
> The worker only generates addresses like `name@<random>.abc.com` — **whether mail actually
> arrives depends entirely on DNS / Email Routing being configured for `*.abc.com`**, and
> Cloudflare Email Routing does **not** propagate the base-domain configuration onto subdomains.
> See Cloudflare's [Email Routing — Subdomains](https://developers.cloudflare.com/email-routing/setup/subdomains/)
> documentation.
>
> It does not automatically create Cloudflare-side subdomain mail routes or DNS records for you,
> so make sure the base-domain/subdomain routing is already available first.
> There are two ways to make `*.abc.com` deliverable:
>
> 1. **DNS-only wildcard MX (simplest workaround)** — In your DNS, copy **every existing MX
> record on `abc.com`** to host `*`, preserving each record's **priority and target value**.
> This makes `*.abc.com` resolve to the same MX targets as the apex, so mail flows into the
> same Cloudflare Email Routing zone and is picked up by the apex Catch-all rule that points
> at the Worker. No extra Cloudflare-side action is needed.
> 2. **Cloudflare dashboard "Add subdomain"** — Add the specific subdomain in the Email Routing
> dashboard and configure its DNS + Catch-all routing rule separately. This only covers the
> specific subdomain you add, so it is not suitable for arbitrary random subdomains.
>
> For random subdomain mailboxes, option **(1)** is the recommended path. Without it, the
> random subdomain feature will appear to work in the UI but inbound mail will never reach the
> Worker.
>
> Reference issue: [#1035](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/1035)
## Let APIs Specify Subdomains Directly
## Let APIs Specify Other Subdomains Directly
If you do not want the system to generate a random subdomain, and instead want the caller to
explicitly create addresses like `team.abc.com`, enable:
If a base domain is not in `RANDOM_SUBDOMAIN_DOMAINS`, but API callers still need to create
addresses like `team.abc.com` directly, enable:
```toml
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
@@ -72,7 +97,7 @@ addresses can be created through `/api/new_address` or `/admin/new_address`:
- `name@dev.team.abc.com`
> [!NOTE]
> This only relaxes the domain validation used by the create-address APIs. It does not change the
> default domain dropdown, and it does not create Cloudflare-side subdomain mail routes for you.
> This switch only relaxes create-address API domain validation. It does not change the frontend
> domain scope or create Cloudflare-side subdomain mail routes for you.
>
> If the admin panel has already saved an override once, you can switch it back to **Follow Environment Variable** to clear the override and return to env fallback behavior.

View File

@@ -111,5 +111,10 @@ To get the url, you need to configure the worker's `FRONTEND_URL` to your fronte
"raw": "${raw}",
"parsedText": "${parsedText}",
"parsedHtml": "${parsedHtml}",
"aiExtractType": "${aiExtractType}",
"aiExtractResult": "${aiExtractResult}",
"aiExtractResultText": "${aiExtractResultText}",
}
```
When AI email extraction is enabled, webhook templates can use the `aiExtractType`, `aiExtractResult`, and `aiExtractResultText` placeholders. They are empty strings when no extraction result is available.

View File

@@ -1,7 +1,7 @@
# Star History
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>

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