Compare commits

...

183 Commits
v1.0.4 ... main

Author SHA1 Message Date
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
Dream Hunter
0b17953097 fix: improve mobile header menu (#1016) 2026-04-29 16:25:15 +08:00
Dream Hunter
ec3e9f0b1f docs: update README feature overview
docs: update readme feature overview
2026-04-29 02:13:17 +08:00
Dream Hunter
fb6e77fded chore: update PR Agent model (#1014)
chore: update pr agent model
2026-04-29 02:07:47 +08:00
Dream Hunter
c703082391 docs: clarify GitHub OAuth email setup
docs: clarify github oauth email setup
2026-04-29 02:04:11 +08:00
Dream Hunter
4e01b4e26f docs: clarify mailbox rebinding and page function actions (#1012)
docs: clarify mailbox rebinding and page functions actions
2026-04-29 01:53:35 +08:00
Dream Hunter
7d68cbdffa docs: improve deployment troubleshooting (#1011)
* docs: improve deployment troubleshooting docs

* docs: fix GitHub casing in FAQ

* docs: clarify subdomain address creation
2026-04-29 01:36:59 +08:00
Lur1n7
6d43c107d8 docs: 添加 CloudMail 移动端管理客户端 (#1007) 2026-04-26 19:47:24 +08:00
Dream Hunter
46fbb4c099 docs: clarify Email Routing setup order (#1006)
docs: clarify email routing setup order
2026-04-25 21:35:14 +08:00
cloverstd
c9698d03f1 docs: highlight that a domain is a prerequisite for deployment (#1005)
In quick-start / worker-vars / email-routing (zh + en), explicitly
call out that a Cloudflare-hosted domain with Email Routing + Catch-all
must be set up before deploying, and that subdomains do not inherit
the parent domain's Email Routing. Closes #1004.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:57:32 +08:00
d 🔹
5f955ccca6 fix(frontend): drop empty/control-char auth headers (#1000) (#1003)
Stale localStorage credentials (`jwt` / `auth` / `adminAuth` / `userJwt` /
`access_token`) can be the empty string, the literal string `"undefined"`,
or carry a stray newline / control character left over from an older
build. axios + undici reject these eagerly with `Invalid character in
header content ["Authorization"]`, so every API call crashes client-side
before reaching the worker.

This adds two tiny helpers in `frontend/src/utils/headers.js`:

- `safeHeaderValue(v)` returns the trimmed value when it is a non-empty
  string with no control chars (per RFC 7230) and no `"undefined"` /
  `"null"` sentinel; otherwise `undefined`.
- `safeBearerHeader(jwt)` wraps a safe JWT with `Bearer `, otherwise
  `undefined`.

`apiFetch` builds the headers object incrementally and only sets each
auth header when its value is safe. Missing/unsafe credentials now drop
out cleanly and the worker returns a normal 401, which the existing
`response.status === 401` flow already handles by surfacing the auth
prompt — the same UX users see on a fresh session.

Tests: `frontend/src/utils/__tests__/headers.test.js` adds 9 vitest
cases covering safe input, sentinel strings, control chars (\\n / \\r /
\\t / NUL / 0x1F / DEL), trimming, and `Bearer` construction. Build
(`pnpm build`) and tests (`pnpm test`) both pass.

Co-authored-by: voidborne-d <voidborne.d@agentmail.to>
Co-authored-by: Dream Hunter <dreamhunter2333@gmail.com>
2026-04-25 16:51:24 +08:00
Dream Hunter
7e7f824f88 fix: constrain address management modal width (#1002)
* fix: constrain address management modal width

* fix: enable address table horizontal scroll
2026-04-25 16:33:18 +08:00
bhwa233
eb62c37e02 feat(i18n): enhance locale handling and routing (#996)
* feat(i18n): enhance locale handling and routing

- Implemented dynamic locale aliases in router configuration.
- Added support for preferred locale storage in global state.
- Improved locale resolution logic in router beforeEach guard.
- Created utility functions for locale management and path manipulation.
- Added tests for locale matching and message extraction.
- Updated Header component to allow language selection.
- Refactored getRouterPathWithLang to utilize new locale utilities.
- Updated Vite configuration to support aliasing for vue-i18n.
- Bumped version numbers across various packages to 1.9.0.

* feat(i18n): update version to 1.8.0 and enhance locale handling

- Updated version numbers across all package.json files to 1.8.0.
- Enhanced locale handling in App.vue by centralizing locale configurations.
- Improved Turnstile component to support dynamic language rendering.
- Refactored i18n utilities to include initial locale setup and empty locale messages.
- Updated i18n.ts to utilize the new locale management structure.
- Added naive-locale.ts for better integration with Naive UI's locale handling.
- Adjusted Header.vue to streamline language selection and locale changes.
- Fixed translations in multiple locale files for consistency and accuracy.

* fix(i18n): address review feedback

* feat(i18n): update default locale to English and enhance language handling in components

* fix(i18n): switch locale selector to dropdown

* docs: add topbar language and github order design spec

* fix(i18n): 修复 Header 语言切换器相关问题,恢复为独立控件并调整样式

* Refactor locale handling in router and add locale-guard utility functions

- Improved locale resolution logic in router by introducing utility functions for better readability and maintainability.
- Added `locale-guard.js` to encapsulate locale-related functions such as getting route locale, resolving locale for navigation, and applying locale navigation state.
- Updated JWT synchronization logic to streamline the handling of JWT from query parameters.
- Modified i18n messages test to check for coverage of registered locale message keys instead of extracting English source messages.

* 删除顶部栏语言和GitHub顺序设计文档

* fix: 修复前端设置初始化时未返回 domains 数组导致的 undefined 错误

* refactor(i18n): consolidate locale infrastructure

* fix(i18n): stabilize locale route switching

* fix(i18n): persist default locale selection

* fix(i18n): 修复前端设置初始化时未返回 domains 数组导致的 undefined 错误,统一按空数组兜底处理
feat(i18n): 添加 locale 别名处理,支持默认语言的重定向
test(i18n): 增加对默认语言别名重定向的测试用例

* refactor: replace useAppI18n with useScopedI18n in multiple components for improved localization management

* fix(tests): 移除不必要的 URL 断言以简化 Passkey 测试

* fix(i18n): 更新语言切换逻辑,确保使用当前语言设置进行路由导航

* fix(i18n): 强制路由切换以确保语言切换后正确导航

* refactor(i18n): 优化消息注册和路由本地化逻辑,移除冗余代码

* refactor(i18n): 拆分 API 文件以优化路由管理,更新语言处理逻辑

* fix: align i18n release notes and frontend test script
2026-04-25 13:46:26 +08:00
bhwa233
1e50bb0933 fix: 修复前端设置初始化时未返回 domains 数组导致的 undefined 错误 (#997) 2026-04-23 11:57:31 +08:00
Dream Hunter
063b6be2b1 Refactor delete setting helper and link skills (#994)
* refactor: add deleteSetting helper and link skills

* chore: reorganize project skills layout

* docs: update skill paths

* chore: add AGENTS link and prune skill links

* chore: localize agent skill links
2026-04-22 00:35:04 +08:00
Dream Hunter
e94ff52add feat: add agent-mail skill, parsed mail API and docs (#993)
* feat: add cf-temp-mail-usage skill and parsed mail API for AI agents

- feat: new /api/parsed_mails and /api/parsed_mail/:id endpoints returning
  server-parsed subject/text/html/attachments metadata (reuses commonParseMail)
- feat: add .claude/skills/cf-temp-mail-usage read-only skill so AI agents
  (OpenClaw / Codex / Cursor) can consume a mailbox with a user-supplied JWT,
  bypassing the Turnstile challenge required for mailbox creation
- refactor: split mails_api/index.ts and admin_api/index.ts into thin route
  shells; move business logic into dedicated *_api.ts files
- docs: update README / README_EN / CHANGELOG with agent-email feature and
  npx degit install instructions for the skill

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: rename skill to cf-temp-mail-agent-mail, add agent-email docs, fix sender trim

- Rename skill from cf-temp-mail-usage to cf-temp-mail-agent-mail
- Rewrite SKILL.md: parsed API primary, local fallback, prerequisites, multi-agent install
- Add vitepress docs (zh + en) for AI Agent mailbox usage
- Fix leading space in parsed_mail_api sender field via .trim()
- Update README install section with 3 install methods
- Update changelogs (zh + en)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: simplify README agent skill section to one-liner with links

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: add send mail API to skill, credential persistence, remove poll example

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 17:09:25 +08:00
Dream Hunter
296ddb8619 chore: bump v1.8.0, add release-notify skill, optimize docs deploy (#992)
- Upgrade version to 1.8.0 in all package.json files
- Add cf-temp-mail-release-notify skill with MarkdownV2 Telegram posting
- Optimize docs_deploy.yml to auto-trigger on Tag Build CI completion
- Add v1.8.0 placeholder in CHANGELOG.md and CHANGELOG_EN.md

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 16:29:17 +08:00
Dream Hunter
a5b64e1dc9 chore: rename project skills with cf-temp-mail prefix (#991)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 15:10:17 +08:00
Dream Hunter
fa19dbbe02 chore: upgrade dependencies (#990)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:53:56 +08:00
jiaxin
ebeb94ed23 fix: auto initialize default send balance (#985)
* fix: auto initialize default send balance

* fix: tighten send access auto init flow

* refactor: centralize send balance state

* fix: separate legacy repair from admin control in send balance

Add an `address_sender.source` column to distinguish legacy / auto /
user / admin rows. `ensureDefaultSendBalance` now only repairs rows
with `source IS NULL`, so admin-disabled and user-requested rows are
never overwritten. Admin POST writes tag `source = 'admin'`; new
auto-init inserts tag `'auto'`; `requestSendMailAccess` inserts tag
`'user'`.

Bumps DB_VERSION to v0.0.8 with the usual `PRAGMA table_info` guarded
ALTER, plus a standalone SQL patch under db/.

Adds E2E regressions: legacy repair path, admin-disabled rows stay
disabled across settings and send, send after admin deletion
auto-initializes a fresh row.

* fix: drop runtime legacy repair; backfill source='legacy' on migrate

Pre-v0.0.8 schema cannot distinguish legacy request-send-access
remnants from admin-disabled rows — both share `balance = 0,
enabled = 0`. Letting ensureDefaultSendBalance repair that shape on
upgrade could silently re-enable an admin-disabled row.

Remove the runtime repair path entirely:

- `ensureDefaultSendBalance` now uses `ON CONFLICT(address) DO NOTHING`;
  existing rows are never touched.
- The v0.0.8 migration (and the matching SQL patch) backfills every
  pre-existing row with `source = 'legacy'`, making pre-migration
  state explicitly off-limits to runtime auto-init.
- E2E: flip the legacy test to the negative direction — a
  `source='legacy'` zero-balance row stays untouched by settings
  reads and send attempts. Harden `resetSenderToLegacy` to return
  404 when `meta.changes < 1`.
- Update changelog and docs: legacy/admin-disabled rows must be
  restored manually via the admin UI.

* refactor: collapse send balance auto-init to missing-row insert

Per review feedback: the runtime guarantee we actually need is
"create an address_sender row when one is missing, leave existing
rows alone". Once `ensureDefaultSendBalance` switched to
`ON CONFLICT DO NOTHING`, the `source` column, the v0.0.8 migration,
and the `resetSenderToLegacy` test endpoint became dead weight —
the DO NOTHING path already protects admin-disabled and admin-edited
rows without any provenance metadata.

- Drop `address_sender.source` and the v0.0.8 migration; revert
  DB_VERSION to v0.0.7. No schema change ships with this PR.
- Strip the `source` field from `ensureDefaultSendBalance`,
  `requestSendMailAccess`, and the admin-update path.
- Remove the `/admin/test/reset_sender_to_legacy` test endpoint and
  its E2E helper; the negative legacy-repair test it served is no
  longer needed because the runtime no longer touches existing rows.
- E2E coverage stays focused on the three guardrails: missing-row
  auto-init, admin-disabled rows stay disabled, admin deletion
  triggers a fresh re-insert.
- Tighten changelog and docs to "auto-initialize missing rows".

* docs: align common-issues with missing-row-only auto-init

The FAQ entries for "DEFAULT_SEND_BALANCE set but still No balance"
still described the old behaviour of repairing legacy
`balance = 0 && enabled = 0` rows. Rewrite both zh and en rows to
match the current runtime: only addresses with no existing
`address_sender` row get auto-initialised; legacy, admin-disabled,
and admin-edited rows must be restored manually through the admin
console.
2026-04-20 12:40:14 +08:00
Dream Hunter
d1fb1f773b Fix send mail form validation (#989)
* fix: harden send mail form validation

* fix: tighten send mail content checks

* fix: refine send mail empty-content checks

* fix: reset send mail preview state
2026-04-18 15:29:48 +08:00
Dream Hunter
5c40eeec80 docs: add SEND_MAIL_DOMAINS docs (#988) 2026-04-18 12:28:15 +08:00
Dream Hunter
000cd0ddfa fix: limit SEND_MAIL domain checks to binding paths (#987)
* fix: scope SEND_MAIL domain gating to binding

* test: cover SEND_MAIL domain gating in e2e
2026-04-17 18:08:19 +08:00
Dream Hunter
e772db8c3e feat: add SEND_MAIL delivery and quota controls (#986)
* feat: add SEND_MAIL delivery and quota controls

* test: cover -1 unlimited runtime for send mail quota

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: split send limit validation and save

* refactor: move send limit counters to settings

* fix: polish send mail limit review follow-ups

* docs: note SEND_MAIL breaking change

* test: align send mail limit e2e with new messages

* fix: address review follow-ups

* fix: harden admin send mail handlers

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 11:37:14 +08:00
Dream Hunter
a5aa475380 chore: upgrade dependencies and bump version to v1.7.0 (#982)
- Upgrade deps across frontend/worker/pages/vitepress-docs (wrangler 4.82.2, dompurify 3.4.0, resend 6.11.0, etc.)
- Bump version to v1.7.0 in all package.json and worker constants
- Add v1.7.0 CHANGELOG placeholder; move #978/#930 Bug Fixes from v1.6.0 to v1.7.0 (merged after v1.6.0 tag)
- Add upgrade-dependencies skill; translate version-upgrade skill to English

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 20:13:40 +08:00
jiaxin
3221f5ae30 fix: lowercase configured address prefixes (#980)
* fix: normalize address casing for password login

Store new mailbox addresses in lowercase and migrate historical address data so mixed-case password logins can read inbox, sendbox, and settings consistently.

* fix: only lowercase configured address prefixes

Limit the #930 change to prefix normalization and document that existing mixed-case data must be migrated manually by users.
2026-04-14 15:59:25 +08:00
jiaxin
15e339282d fix: respect user mail deletion toggle in user center (#979)
* fix: respect user mail deletion toggle in user center

Hide user mailbox delete actions and block /user_api/mails deletion when ENABLE_USER_DELETE_EMAIL is disabled. Add an e2e regression test and changelog entries for issue #978.

* test: hash user password in mail deletion e2e

Use the same SHA-256 pre-hashed password format as the frontend for the user register/login flow in the mail deletion regression test.
2026-04-14 15:25:39 +08:00
dreamhunter2333
e15b1b83d0 ci: upgrade to upload-artifact@v7 and download-artifact@v8 for Node.js 24 2026-04-12 21:26:22 +08:00
dreamhunter2333
c297a49b2a ci: upgrade upload/download-artifact to v4 for Node.js 24 compatibility 2026-04-12 21:24:14 +08:00
dreamhunter2333
de3f9e92ed fix: add checkout step to release job in tag_build workflow 2026-04-12 21:22:05 +08:00
Dream Hunter
832e996dd8 docs: add missing PR #968 to CHANGELOG (#976) 2026-04-12 21:14:09 +08:00
Dream Hunter
e81c9d0d9c docs: add SPA settings screenshot (#975) 2026-04-12 21:03:05 +08:00
Dream Hunter
163df5c908 chore: update dependencies (#974)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:11:25 +08:00
Dream Hunter
c3058817ff feat(admin): add IP whitelist (strict allowlist mode) (#920) (#971)
* feat(admin): add IP whitelist (strict allowlist mode) (#920)

- Add enableWhitelist/whitelist fields to IpBlacklistSettings
- Implement three-layer access control: whitelist → blacklist → daily limit
- Whitelist uses exact match for IPv4/IPv6, regex for patterns
- Whitelisted IPs skip blacklist checks (trusted)
- Fail-closed when cf-connecting-ip missing under whitelist mode
- Frontend: independent whitelist toggle + empty list protection
- Backend: backward compatible (old frontends get defaults)
- E2E tests: config validation + runtime behavior
- Docs: CHANGELOG zh/en updated

Closes #920

* fix(admin): address PR review feedback on IP whitelist

- Add IPv4-mapped IPv6 (::ffff:x.x.x.x) exact match in isWhitelisted
- Include error.message in whitelist regex parse failure log
- Include actual/max size in whitelist size limit error message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): validate whitelist regex on save and preserve existing whitelist on partial update

- Reject invalid regex patterns in whitelist at save time to prevent runtime lockout
- Preserve existing enableWhitelist/whitelist from DB when older clients omit these fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): revert P2 - keep simple ?? defaults for backward compat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): validate whitelist elements are strings before trimming

Prevents 500 error when whitelist contains non-string elements (e.g. numbers, null)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs(admin): add IP blacklist/whitelist documentation (zh + en)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): fix fingerprint blacklist bypass when cf-connecting-ip absent, improve e2e tests

- Split checkBlacklist into checkFingerprintBlacklist (IP-independent) and checkIpAsnBlacklist
- Fingerprint check now runs before the !reqIp early-return to prevent bypass
- Add afterEach reset to config test group, extract RESET_SETTINGS constant
- Strengthen whitelist-blocks test to deterministic 403 assertion
- Add e2e tests: invalid regex rejection, non-string element rejection, fingerprint-blocks-without-IP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(admin): suppress no-useless-escape lint warning in whitelist regex check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:06:13 +08:00
dependabot[bot]
16c4e43871 chore(deps): bump nodemailer and imapflow in /e2e (#963)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) and [imapflow](https://github.com/postalsys/imapflow). These dependencies needed to be updated together.

Updates `nodemailer` from 8.0.4 to 8.0.5
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.4...v8.0.5)

Updates `imapflow` from 1.2.18 to 1.3.1
- [Release notes](https://github.com/postalsys/imapflow/releases)
- [Changelog](https://github.com/postalsys/imapflow/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postalsys/imapflow/compare/v1.2.18...v1.3.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.5
  dependency-type: direct:production
- dependency-name: imapflow
  dependency-version: 1.3.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 20:16:58 +08:00
dreamhunter2333
68cbfb9c32 Revert "feat(admin): add IP whitelist (strict allowlist mode) (#920)"
This reverts commit e18285d3ef.
2026-04-11 20:11:52 +08:00
dreamhunter2333
e18285d3ef feat(admin): add IP whitelist (strict allowlist mode) (#920)
- Add enableWhitelist/whitelist fields to IpBlacklistSettings
- Implement three-layer access control: whitelist → blacklist → daily limit
- Whitelist uses exact match for IPv4/IPv6, regex for patterns
- Whitelisted IPs skip blacklist checks (trusted)
- Fail-closed when cf-connecting-ip missing under whitelist mode
- Frontend: independent whitelist toggle + empty list protection
- Backend: backward compatible (old frontends get defaults)
- E2E tests: config validation + runtime behavior
- Docs: CHANGELOG zh/en updated

Closes #920
2026-04-11 18:49:09 +08:00
Dream Hunter
1584851a36 docs: note that subdomains need Email Routing enabled separately (#970)
Subdomains do not inherit Email Routing from the apex domain;
each subdomain must enable Email Routing and configure its own
DNS records and Catch-all rule.

Refs #969

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 16:32:20 +08:00
YewFence
1cafbbf220 feat(address): 支持最大地址数量设置为 0 表示无限制 (#968)
* feat(address): 支持最大地址数量设置为 0 表示无限制

- 移除角色配置中 =0 时回退到全局设置的逻辑
- 添加负数校验防止无效输入
- 更新前端文案说明 0 表示无限制

* fix(admin): 修复 maxAddressCount 验证逻辑,禁止负数和非对象输入

在 saveRoleAddressConfig 接口增加 configs 参数类型校验,
确保其为有效对象而非数组或 null。同时在 UserSettings 模型中
验证 maxAddressCount 必须大于等于 0,防止无效数据进入系统。

* style: 修正错误的缩进
2026-04-09 17:04:58 +08:00
Dream Hunter
873a10ddb1 docs: simplify D1 naming guidance (#961) 2026-04-08 01:26:28 +08:00
Dream Hunter
9689a1cbca docs: clarify Pages backend URL config (#960)
* docs: clarify pages backend url setup

* docs: refine pages and d1 examples

* docs: harden pages zip generator
2026-04-07 23:59:31 +08:00
Dream Hunter
ef475bab21 chore: upgrade frontend and worker dependencies (#959)
chore: upgrade project dependencies
2026-04-07 19:37:34 +08:00
Dream Hunter
e6ef110ec9 fix: avoid D1 LIKE pattern length limit on admin search (#956) (#957)
D1 caps LIKE/GLOB pattern length at 50 bytes. /admin/address and
/admin/users wrapped the query as `%${query}%` and fed it to LIKE,
so searching by a full email address crashed with "LIKE or GLOB
pattern too complex". Fall back to instr() above the 50-byte
threshold.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 19:23:26 +08:00
Dream Hunter
42281cdc49 ci: upgrade GitHub Actions to support Node.js 24 (#951)
* ci: upgrade GitHub Actions to support Node.js 24

- pnpm/action-setup: v4 → v5
- actions/upload-artifact: v4 → v6
- actions/download-artifact: v4 → v6
- sync.yaml: replace inactive aormsby/Fork-Sync-With-Upstream-action with gh repo sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: replace softprops/action-gh-release with gh release CLI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ci): use gh release upload instead of create for existing releases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:44:07 +08:00
Dream Hunter
5248c03b6c docs: restructure sidebar, expand FAQ, enhance send mail docs (#949)
* docs: restructure sidebar, expand FAQ, enhance send mail docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: remove specific example domain reference in FAQ per review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 11:47:25 +08:00
Dream Hunter
b86d1faac4 docs: update missing documentation from closed issues (#948)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 11:11:44 +08:00
Dream Hunter
a0db913952 fix: remove vite-plugin-top-level-await, incompatible with new esbuild (#940) 2026-04-04 20:08:25 +08:00
Dream Hunter
4746983780 feat: upgrade version to v1.6.0 (#939)
* feat: upgrade version to v1.6.0

- Update version number to 1.6.0 in all package.json files
- Add v1.6.0 placeholder in CHANGELOG.md and CHANGELOG_EN.md

* docs: update release skill to use bilingual format (zh + en collapsed)

* chore: upgrade dependencies

* fix: correct CHANGELOG placeholder position and update version-upgrade skill

* docs: update version-upgrade skill with correct CHANGELOG placeholder position
2026-04-04 19:58:47 +08:00
Dream Hunter
f08d062b32 docs: clarify ENABLE_MAIL_GZIP guidance (#938) 2026-04-04 19:12:07 +08:00
Dream Hunter
8885948291 docs: add ENABLE_MAIL_GZIP to wrangler.toml.template (#937)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:51:58 +08:00
Dream Hunter
7c6d0d7c8a feat(mail): support gzip compressed email storage via ENABLE_MAIL_GZIP (#933)
* feat(mail): support gzip compressed email storage in D1 raw_blob column

Add ENABLE_MAIL_GZIP env var to optionally gzip-compress incoming emails
into a new raw_blob BLOB column, saving D1 storage space. Reading is
backward-compatible: prioritizes raw_blob (decompress) with fallback to
plaintext raw field. Includes DB migration v0.0.7, docs, and changelogs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: gzip fallback on missing column + decouple resolve from handleListQuery

- email/index.ts: gzip INSERT failure now falls back to plaintext INSERT
  instead of silently losing the email (P1: data loss prevention)
- common.ts: add handleMailListQuery for raw_mails-specific list queries
  with resolveRawEmailList, keeping handleListQuery generic
- Replace handleListQuery → handleMailListQuery in mails_api, admin_mail_api,
  user_mail_api (only raw_mails callers)
- Add e2e test infrastructure: worker-gzip service, wrangler.toml.e2e.gzip,
  api-gzip playwright project, mail-gzip.spec.ts with 4 test cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback for gzip feature

- Use destructuring in resolveRawEmailRow to truly remove raw_blob key
- Narrow fallback scope: only fallback to plaintext on compression failure
  or missing raw_blob column, re-throw other DB errors
- Clean unused imports in e2e gzip test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add try-catch in resolveRawEmail to prevent single corrupt blob from failing entire list

A corrupted raw_blob would cause decompressBlob to throw, which with
Promise.all in resolveRawEmailList would reject the entire batch query.
Now catches decompression errors and falls back to row.raw field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mail): align sendAdminInternalMail with gzip storage path

sendAdminInternalMail now respects ENABLE_MAIL_GZIP: compresses to
raw_blob when enabled, with fallback to plaintext on failure.
Added e2e test verifying admin internal mail is readable under gzip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): match admin internal mail by body content instead of encoded subject

mimetext base64-encodes the Subject header, so the raw MIME string
does not contain the literal subject text. Match on body content
(balance: 99) which is plaintext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(e2e): add WORKER_GZIP_URL guard and length assertions in gzip tests

Address CodeRabbit feedback:
- Skip gzip tests when WORKER_GZIP_URL is not set to prevent false positives
- Assert results array length before accessing [0] for clearer error messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mail): narrow gzip fallback scope and fix webhook query compatibility

- sendAdminInternalMail: separate compress vs DB error handling, only
  fallback to plaintext on compression failure or missing raw_blob
  column, rethrow other DB errors (aligns with email/index.ts)
- Webhook test endpoints: use SELECT * instead of explicit raw_blob
  column reference, so pre-migration databases don't 500
- Docs/changelog: clarify that db_migration must run before enabling
  ENABLE_MAIL_GZIP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(telegram): use generic Record type for raw_mails query result

Align with other query sites — avoid hardcoding raw_blob in the
TypeScript type annotation so the query works with or without the
column after migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(models): add RawMailRow type and unify raw_mails query typing

Add RawMailRow type to models with raw_blob as optional field, replacing
ad-hoc Record<string, unknown> and inline type annotations across
webhook test endpoints, telegram API, and gzip utilities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 18:46:39 +08:00
Dream Hunter
53c35062c8 docs: add delete-address api docs (#936) 2026-04-04 18:33:56 +08:00
majorcheng
1a7cfb8c95 feat: 支持创建邮箱 API 的子域名后缀匹配开关 (#929)
* feat: 支持创建邮箱 API 的子域名后缀匹配开关

* fix: 修复 review 提到的开关三态与域名校验问题

* fix: 补充域名归一化与子域名匹配回归测试

* fix: 修复后台开关跟随 env 回退与 account_settings 半成功保存

* fix: 收口账号设置刷新提示与子域名状态重复读取

* fix: 拦截超长域名并透传账号设置刷新失败
2026-04-04 00:11:23 +08:00
Dream Hunter
d2c940aa2c feat(admin): add column sorting and reset pagination on search (#927)
* feat(admin): add column sorting and reset pagination on search (#918)

- Add server-side column sorting for admin address list (ID, name, created_at, updated_at, mail_count, send_count)
- Reset pagination to page 1 when searching or changing sort order
- Add optional orderBy parameter to handleListQuery with whitelist validation

Closes #918

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add JSDoc warning for orderBy parameter in handleListQuery

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address code review findings

- Fix count not resetting to 0 when search returns empty results
- Add source_meta column sorting support
- Use Object.hasOwn to prevent prototype pollution in sort column lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 01:46:12 +08:00
tsymr
db93828a81 feat(subdomain): add random second-level mailbox support (#924)
Summary: add random second-level subdomain mailbox creation for web, admin, and
  Telegram.

Scope: worker config, UI toggle, and README/VitePress documentation.

Co-authored-by: wufei <fwu@creams.io>
2026-04-02 23:13:10 +08:00
dependabot[bot]
be1bf71a47 chore(deps): bump nodemailer and imapflow in /e2e (#916)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) and [imapflow](https://github.com/postalsys/imapflow). These dependencies needed to be updated together.

Updates `nodemailer` from 8.0.1 to 8.0.4
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.1...v8.0.4)

Updates `imapflow` from 1.2.12 to 1.2.18
- [Release notes](https://github.com/postalsys/imapflow/releases)
- [Changelog](https://github.com/postalsys/imapflow/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postalsys/imapflow/compare/v1.2.12...v1.2.18)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.4
  dependency-type: direct:production
- dependency-name: imapflow
  dependency-version: 1.2.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 14:55:53 +08:00
BobDLA
424991a165 fix: surface backend deploy errors in GitHub Actions (#917) 2026-03-29 01:48:17 +08:00
Dream Hunter
c97a9a278b docs: clarify Address JWT vs User JWT and reorganize API menu (#914)
- Add warning notes in new-address-api and mail-api docs
- Explain the difference between Address JWT and User JWT
- Create dedicated 'API Endpoints' section in sidebar
- Update both zh and en documentation

Refs #910
2026-03-26 02:10:04 +08:00
Dream Hunter
a45d01f9fd feat: return address_id in /admin/new_address response (#913)
* feat: return address_id in /admin/new_address response

- Add address_id field to newAddress function return type
- Update CHANGELOG.md and CHANGELOG_EN.md

Fixes #912

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: verify address_id in new_address response

* fix: add address_id validation and improve test coverage

- Add null check for address_id after DB query
- Change address_id to required field in return type
- Add dedicated test for /admin/new_address endpoint
- Update e2e helper return type to non-optional

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 00:18:15 +08:00
Dream Hunter
03965f3612 fix(imap): fix mojibake in nested emails, empty headers, and date handling (#909)
* fix(imap): fix mojibake in nested emails, empty headers, and date handling

- Add line-by-line mojibake fix fallback for complex emails with mixed content
- Apply empty header cleanup globally to fix nested message/rfc822 parts
- Add locale-independent date formatting (format_imap_date, format_rfc2822_date)
- Fill missing Date header from created_at field
- Fix getSubPart for non-multipart messages
- Accept CREATE requests from clients (e.g. Gmail creating Drafts)
- Strip whitespace from IMAP password
- Use MIMEText instead of MIMEMultipart for sent mail generation
- Keep body in original CTE encoding for correct BODYSTRUCTURE
- Update CHANGELOG (zh/en)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: consolidate IMAP changelog entries into single line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 20:52:18 +08:00
greenhandzdl
64d11799b3 fix:Prevent shell from parsing configuration file errors (#908)
* fix:unsuccessful run

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-22 16:47:13 +08:00
dependabot[bot]
10f1f1f32b chore(deps): bump pyopenssl from 25.3.0 to 26.0.0 in /smtp_proxy_server (#902)
Bumps [pyopenssl](https://github.com/pyca/pyopenssl) from 25.3.0 to 26.0.0.
- [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/pyopenssl/compare/25.3.0...26.0.0)

---
updated-dependencies:
- dependency-name: pyopenssl
  dependency-version: 26.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-19 20:32:10 +08:00
Dream Hunter
e77ab12140 docs: clarify mail API raw payload semantics (#906)
docs: clarify mail API returns raw RFC822 content
2026-03-19 20:31:22 +08:00
Dream Hunter
79b9835fa2 docs: add changelog for OAuth2 sessionStorage fallback (#900) (#901)
* docs: add changelog for OAuth2 sessionStorage fallback (#900)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: mention Android via browser in changelog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:07:58 +08:00
Dream Hunter
6c58cd3c2e fix: add localStorage fallback for OAuth2 session state on mobile browsers (#900)
* fix: add localStorage fallback for OAuth2 session state on mobile browsers

Some mobile browsers (Safari ITP, WebViews) lose sessionStorage during
cross-origin OAuth2 redirects. Add localStorage fallback via computed
wrapper that dual-writes on set and reads sessionStorage-first on get.
Also cleanup state in finally block to ensure one-time consumption.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: i18n for 'code not found' in OAuth2 callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 00:04:00 +08:00
Dream Hunter
eeea512ab1 fix: use native fetch for Telegram attachment upload (#898)
* fix: use native fetch for Telegram attachment upload

telegraf's sendMediaGroup uses Node.js streams (multipart-stream) for
file uploads, which is incompatible with CF Workers runtime, causing
"SyntaxError: Unexpected end of JSON input".

Replace with native fetch + FormData + attach:// protocol which works
correctly in CF Workers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wrap sendTelegramAttachments in top-level try-catch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 03:15:04 +08:00
Dream Hunter
e35c246757 fix: mail-parser-wasm missing message/rfc822 attachments (#897)
* fix: mail-parser-wasm treat message/rfc822 attachments as regular attachments

Previously, message/rfc822 attachments (e.g. .eml files) were
recursively parsed for sub-attachments instead of being returned
directly, causing them to be silently dropped. Now all attachments
are returned regardless of type.

Bump version to 0.2.2. Add .gitignore for worker build artifacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: add missing entries to worker .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: bump mail-parser-wasm to 0.2.2 in frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 02:52:13 +08:00
Dream Hunter
e7df77cac0 fix: update address timestamp on send mail and refactor TG attachment guard (#896)
fix: update address timestamp on send mail, refactor TG attachment guard

- Call updateAddressUpdatedAt after successful send mail to keep
  address activity timestamp up to date
- Refactor Telegram attachment push: replace early return with if block
  to prevent skipping future logic after attachment section

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 02:39:18 +08:00
Dream Hunter
9ee21da8a9 feat: support attachment push for Telegram and Webhook (#895)
* feat: support attachment push for Telegram and Webhook (#894)

- Parse email attachments via postal-mime in commonParseMail
- Send attachments via Telegram Bot API sendDocument after text message
- Include base64-encoded attachments in webhook payload
- Add e2e tests for webhook attachment push
- Add i18n messages for attachment-related notifications

Closes #894

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove user-facing error message for failed attachment send

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove unused i18n attachment messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use sendMediaGroup for batch attachment sending

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove redundant commonParseMail call, use cached result

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove webhook attachment support, raw already contains attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use sendDocument for single attachment, sendMediaGroup for 2+

Telegram sendMediaGroup requires 2-10 items minimum. Use sendDocument
for single attachment case. Update CHANGELOG with 50MB limit info.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: batch sendMediaGroup in groups of 9, add attachments to wasm parser

Telegram sendMediaGroup supports 2-10 items. Batch large attachment
lists into groups of 9. Also add attachments field to commented-out
wasm parser for future compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add caption to attachment messages, update wasm patch

Add email sender and subject as caption on Telegram attachment messages.
Caption is shown on the first attachment only for sendMediaGroup.
Update wasm parser patch to include attachments field mapping, and fix
wasm comment to use correct field names (content_type, content as
Uint8Array directly).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: unify attachment sending with sendMediaGroup for all cases

sendMediaGroup works with 1+ files (tested). Remove sendDocument
special case and always use sendMediaGroup with batching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: reduce sendMediaGroup batch size to 6

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: change WASM parse email comment from TODO to NOTE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: regenerate wasm parser patch with attachments support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add ENABLE_TG_PUSH_ATTACHMENT env var to control attachment push

Add environment variable to enable/disable Telegram attachment push
(default disabled). Update type definitions, wrangler template,
worker-vars docs (zh/en), telegram feature docs (zh/en), and
changelogs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 02:10:48 +08:00
Dream Hunter
5bb053fb7b chore: upgrade deps and GitHub Actions for Node.js 24 (#889)
chore: upgrade dependencies and GitHub Actions for Node.js 24 compatibility

Upgrade GitHub Actions to fix Node.js 20 deprecation warnings:
- actions/checkout v4 → v6
- actions/setup-node v4 → v6
- pnpm/action-setup v3 → v4
- docker/login-action v3 → v4
- docker/setup-qemu-action v3 → v4
- docker/setup-buildx-action v3 → v4
- docker/build-push-action v5 → v7
- node-version 20 → 22, pnpm 8 → 10

Upgrade project dependencies:
- frontend: vue 3.5.30, naive-ui 2.44.1, dompurify 3.3.3, @unhead/vue 2.1.12
- worker: hono 4.12.7, @cloudflare/workers-types, typescript-eslint 8.57.0
- all: wrangler 4.72.0

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 01:27:03 +08:00
Dream Hunter
7d880ef340 fix: user email regex setting visibility (#888)
fix: keep email regex option visible in user settings
2026-03-12 01:15:22 +08:00
Dream Hunter
e6cc8e2ffd feat: add Turnstile CAPTCHA for login forms (#767) (#885)
* feat: add Turnstile CAPTCHA for login forms (#767)

Add optional Turnstile verification for admin login, user login, and
address password login via ENABLE_LOGIN_TURNSTILE_CHECK env var.
Does not affect existing Turnstile on address creation / registration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add ENABLE_LOGIN_TURNSTILE_CHECK to wrangler.toml.template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: ensure openSettings loaded before admin login modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Turnstile to site access password and fix settings field name

- Add Turnstile to site access password modal in Header.vue
- Add /open_api/site_login endpoint for password + Turnstile verification
- Fix settings field name from enableTurnstileLogin to enableLoginTurnstileCheck

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move login endpoints to open_api/auth.ts

Move /open_api/site_login and /open_api/admin_login from commom_api.ts
to a dedicated open_api/auth.ts file for better code organization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: change Turnstile check failure status from 500 to 400

Turnstile validation failure is a client error, not a server error.
Change all Turnstile check error responses from 500 to 400.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use unique IDs for multiple Turnstile instances

When multiple modals with Turnstile appear simultaneously (e.g., site
access + admin login), the hardcoded id="cf-turnstile" causes conflicts.
Generate a unique container ID per Turnstile instance to fix this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: review fixes - cfToken separation, register Turnstile, error codes

- Separate cfToken refs in Login.vue to avoid token sharing between
  login and new address creation Turnstile instances
- Add Turnstile check to user registration endpoint (not just verify_code)
- Show Turnstile on register tab regardless of enableMailVerify
- Pass cf_token in register request body
- Fix site_login error message to use CustomAuthPasswordMsg
- Fix verifyCode Turnstile error status from 500 to 400
- Restore empty line in commom_api.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: separate register Turnstile logic for with/without mail verify

- With mail verify: verify_code already checks Turnstile, register
  skips Turnstile (token is one-time use)
- Without mail verify: register checks Turnstile directly
- Separate loginCfToken for login tab to avoid token sharing with
  register tab Turnstile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add enableLoginTurnstileCheck to store defaults, simplify changelog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add /open_api/credential_login for credential login verification

Add credential_login endpoint that verifies both Turnstile token and
JWT credential server-side, replacing the generic verify_turnstile
endpoint. Credential login now validates the JWT before accepting it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: improve login endpoints - hash passwords, expose Turnstile refresh, fix status codes

- site_login/admin_login: always called, verify hashed password + optional Turnstile
- credential_login: always called, verify JWT + optional Turnstile
- Frontend sends hashed passwords instead of plaintext
- Turnstile component exposes refresh method via defineExpose
- Fix Turnstile error status 500→400 in mails_api and telegram_api

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename to ENABLE_GLOBAL_TURNSTILE_CHECK and add isGlobalTurnstileEnabled helper

- Rename ENABLE_LOGIN_TURNSTILE_CHECK -> ENABLE_GLOBAL_TURNSTILE_CHECK
- Add isGlobalTurnstileEnabled() in utils.ts: checks env var + Turnstile keys all present
- Backend settings returns enableGlobalTurnstileCheck computed from the helper
- All backend endpoints use isGlobalTurnstileEnabled(c) instead of raw env check
- Update all frontend refs, docs, changelog, and wrangler template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use utils.isGlobalTurnstileEnabled instead of named import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add E2E tests for turnstile login endpoints

- Test all 3 new /open_api/* endpoints when ENABLE_GLOBAL_TURNSTILE_CHECK is disabled
- Verify settings returns enableGlobalTurnstileCheck: false
- Test admin_login with correct/wrong/empty hashed password
- Test site_login returns 401 when no PASSWORDS configured
- Test credential_login with valid JWT, invalid JWT, empty credential
- Test address_login with empty cf_token works when turnstile disabled
- Add ADMIN_PASSWORDS to E2E wrangler config for admin_login tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: rename test file to login-endpoints.spec.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: validate JWT payload has address field in credential_login

Prevents user tokens or challenge tokens from being accepted as
address credentials since they share the same JWT_SECRET.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: refresh Turnstile token on login failure to allow retry

After a failed login attempt, the consumed Turnstile token is now
refreshed so users can retry without manually refreshing.
Also adds ref to signup Turnstile in UserLogin.vue to refresh after
verification code is sent (single-use token consumed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: separate Turnstile tokens for signup and reset password flows

Split shared cfToken into signupCfToken and resetCfToken to prevent
single-use Turnstile token conflicts between signup tab and reset
password modal. Each flow now has its own token ref and refreshes
the correct Turnstile widget after use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update comments from "login turnstile" to "global turnstile"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:47:26 +08:00
Dream Hunter
94c606959f docs: add webhook preset templates and Telegram push docs (#884)
docs: add webhook preset templates and Telegram per-user push docs (#769)

Add Telegram Bot, WeChat Work, Discord webhook preset templates to
frontend and documentation. Add per-user mail push and global push
documentation for Telegram Bot.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:45:05 +08:00
Dream Hunter
75236e6a53 fix: passkey user verification compatibility for v13 (#883)
fix: disable requireUserVerification for passkey auth compatibility

@simplewebauthn/server v13 defaults requireUserVerification to true,
causing "User verification required, but user could not be verified"
errors for existing passkeys and authenticators that don't enforce UV.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 03:06:54 +08:00
Dream Hunter
13c3879033 docs: update CLAUDE.md with auth, e2e, and architecture (#882)
docs: update CLAUDE.md with auth corrections, e2e testing, and architecture

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:25:13 +08:00
Dream Hunter
c5893a2944 chore: upgrade dependencies (#881)
* chore: upgrade dependencies

- dompurify 3.3.1 → 3.3.2
- naive-ui 2.43.2 → 2.44.0
- vue-i18n 11.2.8 → 11.3.0
- @cloudflare/workers-types 4.20260305.1 → 4.20260307.1
- @types/node 25.3.3 → 25.3.5
- wrangler 4.70.0 → 4.71.0 (all subprojects)

* feat: upgrade @simplewebauthn packages from v10 to v13

Breaking changes addressed:
- [v11] startRegistration/startAuthentication now take object param
- [v11] registrationInfo.credential replaces flat destructuring
- [v11] authenticator param renamed to credential in verifyAuthenticationResponse
- [v13] @simplewebauthn/types removed, types imported from @simplewebauthn/server

Packages:
- @simplewebauthn/server: 10.0.1 → 13.2.3
- @simplewebauthn/browser: 10.0.0 → 13.2.2
- @simplewebauthn/types: removed (deprecated)

* test: add passkey API E2E tests

- User registration and login flow
- register_request/authenticate_request return valid WebAuthn options
- authenticate_response with invalid credential returns 404
- register_response with invalid credential returns error
- Passkey list empty for new user
- Rename/delete operations with validation

* fix: use UI login instead of localStorage injection in browser passkey test

The localStorage approach doesn't work with VueUse's useStorage because
it doesn't detect external changes during page navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: hash password before registration to match frontend login behavior

The frontend hashes passwords with SHA-256 before sending to the API.
Registration via API must use the same hashed password so that UI login
matches the stored value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: allow crypto.subtle in Docker browser tests

The frontend uses crypto.subtle for password hashing, which requires
a secure context (HTTPS or localhost). In Docker, the frontend runs
at http://frontend:5173 which is not a secure context. Add Chromium
flag to treat this origin as secure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: serve frontend over HTTPS in Docker for WebAuthn secure context

WebAuthn (navigator.credentials) and crypto.subtle both require a
secure context (HTTPS or localhost). The Docker frontend was serving
over HTTP, making passkey operations impossible.

Changes:
- Generate self-signed cert in Dockerfile.frontend
- Configure Vite to serve over HTTPS
- Update FRONTEND_URL to https://
- Add ignoreHTTPSErrors to Playwright browser config
- Use localStorage injection for passkey test login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add Vite proxy to avoid mixed-content blocking in HTTPS Docker frontend

HTTPS pages cannot make HTTP API requests (mixed content). Add a Vite
proxy for all API paths so the browser makes same-origin HTTPS requests,
which Vite proxies to the HTTP worker server-to-server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: store userJwt without JSON.stringify in localStorage

VueUse's useStorage with a string default uses raw string serialization
(no JSON wrapping). Using JSON.stringify added double quotes around the
JWT token, causing 401 Unauthorized from the worker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: clean up passkey API test per review feedback

Remove unused variables and rename test to match actual behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:18:17 +08:00
Dream Hunter
5f3762ef58 fix: auto-reply not triggering when source_prefix is empty (#880)
* fix: auto-reply not triggering when source_prefix is empty (#459)

- Empty source_prefix now matches all senders (was short-circuiting as falsy)
- Support regex matching with /pattern/ syntax in source_prefix
- Backward compatible: plain strings still use startsWith
- Use E2E_TEST_MODE switch to skip cloudflare:email import in tests
- Track reply() calls in E2E mock for testability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update auto-reply UI labels for regex support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update changelogs for auto-reply fix and regex feature

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: upgrade version to v1.5.0

- Update version number to 1.5.0 in all package.json files and constants.ts
- Split CHANGELOG: v1.4.0 entries finalized, new v1.5.0(main) section with auto-reply changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add error logging for invalid regex in auto-reply source_prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: address CodeRabbit review suggestions

- Use const object instead of let for mock state tracking
- Add log when auto-reply subject/message falls back to defaults

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add source_prefix regex syntax to auto-reply docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:08:06 +08:00
Dream Hunter
10873e7887 feat: add release skill for automated GitHub release (#879)
* feat: add release skill for automated GitHub release creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use --notes-file instead of --notes for release creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add time-bound filter for PR collection in release skill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use hyphenated cache-clearing in release skill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:36:19 +08:00
Bowl42
fca9bade48 feat: add webhook preset templates for Message Pusher, Bark, and ntfy (#877) 2026-03-06 19:53:08 +08:00
Dream Hunter
f5ca8afcce test: add E2E tests for webhook trigger on incoming mail (#878)
* test: add E2E tests for auto-reply trigger and webhook trigger

- Improve mock reply() in e2e_test_api.ts to send auto-replies via
  SMTP (WorkerMailer) so they reach Mailpit for verification
- Add auto-reply-trigger.spec.ts: verifies auto-reply is sent when
  incoming mail matches source_prefix, and NOT sent otherwise
- Add webhook-trigger.spec.ts: starts a temporary HTTP server to
  receive webhook calls, verifies payload on mail arrival

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: fallback EmailMessage for E2E auto-reply when cloudflare:email is unavailable

In wrangler dev mode without Email Routing binding, `import('cloudflare:email')`
throws, silently caught by auto_reply's try-catch. Add a fallback that constructs
a plain object with a ReadableStream `raw` property so the E2E mock reply() can
send the auto-reply via SMTP to Mailpit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle both \r\n and \n line endings in MIME parser for E2E tests

mimetext uses os.EOL which is \n on Linux (Docker). The parseMimeForReply
function only looked for \r\n, causing it to fail parsing the auto-reply
MIME content in the E2E environment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add debug logging and robust raw MIME extraction for E2E auto-reply

- auto_reply.ts: add fallback ReadableStream when cloudflare:email
  is unavailable, attach rawMime directly to replyMessage
- e2e_test_api.ts: try reading rawMime string first, then fallback
  to ReadableStream; add diagnostic console.log for CI debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: skip sealed EmailMessage in E2E mode, await webhook server listen

- auto_reply.ts: use plain object with raw ReadableStream in E2E_TEST_MODE
  (cloudflare:email's EmailMessage is sealed, can't attach extra properties)
- e2e_test_api.ts: simplify mock reply() to read raw ReadableStream directly,
  add defensive check for from without @
- webhook-trigger.spec.ts: await server.listen to ensure socket is bound
  before sending requests (CodeRabbit review feedback)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add missing await for async startWebhookReceiver in disabled test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: drop auto-reply E2E test, clean up webhook test

- Remove auto-reply-trigger.spec.ts (cannot test without modifying
  production auto_reply.ts due to sealed EmailMessage from cloudflare:email)
- Clean up e2e_test_api.ts: remove WorkerMailer, MIME parsing, and SMTP
  reply logic that was only needed for auto-reply testing
- Improve webhook test: use dynamic port allocation (port 0) instead of
  hardcoded WEBHOOK_PORT to avoid port conflicts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: assert webhook request path in E2E test

Add path assertion to verify webhook request hits /webhook endpoint,
preventing false positives from incorrect routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:41:29 +08:00
Dream Hunter
8cf1150b15 feat: add STARTTLS support for SMTP proxy server (#876)
* feat: add STARTTLS support for SMTP proxy server

Add smtp_tls_cert and smtp_tls_key environment variables to enable
STARTTLS on the SMTP proxy server, matching existing IMAP TLS support.

Closes #249

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add E2E tests for SMTP/IMAP STARTTLS

- Add smtp-proxy-tls service with self-signed certs in docker-compose
- Add smtp-tls.spec.ts: SMTP STARTTLS send plain/HTML/auth tests
- Add imap-tls.spec.ts: IMAP STARTTLS login/list/select/fetch tests
- Register smtp-proxy project in playwright.config.ts
- Wait for TLS proxy readiness in docker-entrypoint.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: enforce auth over TLS when STARTTLS is configured

- Set auth_require_tls conditionally based on tls_context presence
- Disable insecure SSLv2/SSLv3 protocols in TLS context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace cert-gen service with inline cert generation

The cert-gen one-shot container was exiting immediately after
generating certificates, triggering --abort-on-container-exit
and stopping all services before tests could run.

Replace with an entrypoint script in smtp-proxy-tls that generates
the self-signed cert before starting the proxy server.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:05:29 +08:00
Dream Hunter
8341cae28f fix: support admin auth for Telegram MiniApp mail viewing (#875)
* fix: support admin auth for Telegram MiniApp mail viewing (#852)

Admin users configured in miniAppUrl can now view emails via the
MiniApp without needing Telegram initData auth. The getMail endpoint
reads the x-admin-auth header (already sent by the frontend) to
bypass Telegram auth and address permission checks for admin users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract isAdmin() as shared utility function

Reuse the x-admin-auth header check logic across worker.ts and
miniapp.ts via a common isAdmin() helper in utils.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename isAdmin to checkIsAdmin for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review comments

- Remove unused getAdminPasswords import from worker.ts
- Return 404 when admin queries a non-existent mail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:06:49 +08:00
Bowl42
635e0f4456 chore(deps): bump dompurify from 3.3.1 to 3.3.2 in /frontend (#874)
Security fix: XSS bypass via jsdom raw-text tag parsing,
prototype pollution with custom elements, and lenient config
parsing in _isValidAttribute.

Supersedes #872

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:46:57 +08:00
Bowl42
e81d46262d chore(deps): bump playwright from 1.49.0 to 1.58.2 (#873)
Update @playwright/test and Docker base image together to fix
CI failures caused by mismatched browser versions.

Closes #864

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:40:44 +08:00
Bowl42
13426b2fbd test: add E2E tests for webhook settings (#871)
* test: add E2E tests for webhook settings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: verify headers and body fields in webhook roundtrip test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Dream Hunter <dreamhunter2333@gmail.com>
2026-03-06 11:35:04 +08:00
Dream Hunter
2a52fd35d5 refactor: modularize IMAP server with dual login, STARTTLS, and test suite (#859)
refactor: modularize IMAP server with fixes and E2E tests

- Modularize IMAP server into imap_server, imap_mailbox, imap_message,
  imap_http_client, parse_email, config, models
- Support dual login: JWT token and address+password via backend
- Add STARTTLS support with configurable TLS cert/key
- Fix FETCH/STORE returning UID instead of sequence number (RFC 3501)
- Implement IMessageFile.open() for correct BODY[] raw MIME delivery
- Add UIDNEXT to SELECT response via _cbSelectWork override
- Use per-restart UIDVALIDITY to force client resync
- Pass raw MIME to SimpleMessage for accurate RFC822.SIZE
- Fix SENT mailbox returning empty source
- Handle CREATE command gracefully for Thunderbird compatibility
- Add IMAP E2E tests: auth, LIST, SELECT, STATUS, FETCH, SEARCH,
  STORE, UID FETCH, BODY[] integrity, size, seq numbers, SENT mailbox
- Add SMTP E2E tests using nodemailer: send plain/HTML, auth failure,
  sendbox verification
- Add sendTestMail helper using admin/send_mail

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:08:10 +08:00
Dream Hunter
e38015a5b6 refactor: E2E tests use real email() handler (#870)
* refactor: add receive_mail E2E endpoint using real email() handler

Add /admin/test/receive_mail that constructs a mock ForwardableEmailMessage
and calls the real email() handler, so E2E tests exercise the full mail
processing pipeline. Extract both test endpoints into e2e_test_api.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: trigger CI

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:15:02 +08:00
Bowl42
f98bbce234 test: add E2E tests for auto-reply settings (#868)
* test: add E2E tests for auto-reply settings

Add auto-reply.spec.ts with two test cases:
- GET empty → POST save → GET verify saved fields
- POST with too-long subject returns 400

Enable ENABLE_AUTO_REPLY in E2E wrangler config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add full fields and body assertion for too-long validation per review

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:22:25 +08:00
Dream Hunter
2f8183e024 fix: correct API path typo requset → request (#869)
* fix: correct API path typo `requset_send_mail_access` → `request_send_mail_access`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct typo in send-access E2E test (requset → request)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:33:06 +08:00
Bowl42
e115b99271 test: add E2E test for clearing sent items (#867)
* test: add E2E test for clearing sent items

Add clear-sent.spec.ts verifying:
- Send a mail → verify it appears in sendbox → clear sent items → verify sendbox is empty

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: remove unused address variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:09:17 +08:00
Bowl42
12ab6e1430 test: add E2E test for duplicate send access request (#866)
* test: add E2E test for duplicate send access request

Add send-access.spec.ts verifying:
- First request succeeds and balance matches DEFAULT_SEND_BALANCE
- Duplicate request returns 400 (UNIQUE constraint)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: assert response body contains 'Already' for duplicate send access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:09:08 +08:00
Bowl42
6e6e3f0877 test: add E2E tests for fetching single mail by ID (#865)
Add mail-detail.spec.ts with two test cases:
- Fetch a seeded mail by ID and verify fields (id, address, source, raw)
- Fetch non-existent mail ID returns null

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:09:02 +08:00
Bowl42
286dfabd5f test: add E2E tests for address password login (#863) 2026-03-05 21:43:07 +08:00
Bowl42
aaae21e92e test: add E2E tests for mail deletion and inbox clearing (#862) 2026-03-05 20:53:10 +08:00
Dream Hunter
3df55dce91 chore: upgrade dependencies across all subprojects (#861) 2026-03-05 20:27:59 +08:00
Bowl42
13b009f6ab test: add Dockerized E2E test environment with Playwright + Mailpit (#860) 2026-03-05 20:12:43 +08:00
Bowl42
0c337a1942 fix: sanitize mail content in reply/forward to prevent XSS (#857)
* fix: sanitize mail content in reply/forward to prevent XSS

- Add DOMPurify to sanitize HTML email content (whitelist-based)
- Add escapeHtml for plain text content (escape &<>"')
- Guard mail.originalSource with fallback to empty string
- Add jsdom for vitest DOM environment (DOMPurify requires DOM)
- Add XSS regression tests (script tags, event handlers, HTML escape)
- Add contentType assertion for empty message fallback case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add XSS sanitization E2E screenshots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove temporary screenshots from tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: normalize escapeHtml input and add forward text escape test

- escapeHtml: convert input via String(str ?? '') to handle non-string values
- Add test for plain text forward with special chars (<, &, >)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:30:43 +08:00
Bowl42
372f7b4149 fix: preserve HTML content when replying to HTML emails (#856)
* fix: preserve HTML content when replying to HTML emails (#728)

Reply was using curMail.text (plain text) instead of curMail.message (HTML),
causing loss of original email formatting. Forward already used HTML correctly.

Now reply prefers HTML content with plain text fallback, matching forward behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add vitest unit tests for reply/forward mail logic

Extract buildReplyModel and buildForwardModel into testable utility
functions and add 13 unit tests covering HTML content preservation,
plain text fallback, sender parsing, and subject formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove unnecessary vitest exclude config

The e2e files have been deleted, so the test.exclude config in
vite.config.js is no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: revert unnecessary trailing comma in vite.config.js

Restore vite.config.js to match main exactly — no changes needed
for this PR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add e2e screenshots for PR review

Screenshots from local Playwright test showing:
1. HTML email rendered correctly in inbox
2. Reply editor preserving HTML content in blockquote

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove temporary test screenshots

Screenshots have been posted as PR comment, no longer needed in tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use html contentType for HTML email replies instead of rich

wangEditor (rich text editor) strips block-level HTML tags inside
blockquote, losing all formatting. Use contentType 'html' for HTML
email replies (matching forward behavior) so content is edited as
raw HTML in a textarea, preserving all formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: update e2e screenshots showing HTML formatting preserved

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove temporary screenshots from tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add SMTP send flow E2E screenshots with mailpit

Screenshots showing complete SMTP HTML email reply flow:
1. View rich HTML email (gradient headers, tables, badges)
2. Reply compose with HTML mode (textarea, not wangEditor)
3. Sent box showing preserved HTML formatting
4. Mailpit inbox receiving the SMTP email
5. Mailpit email detail with full HTML rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: remove temporary SMTP test screenshots from tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:51:27 +08:00
Bowl42
abad88b986 fix: improve email content readability in dark mode (#855) 2026-03-04 20:52:19 +08:00
Dream Hunter
928a35b7cb docs: update CLAUDE.md and remove AGENTS.md (#854)
Consolidate AGENTS.md into CLAUDE.md with enhanced guidance:
- Add post-task checklist requiring changelog and docs updates
- Add auth headers reference
- Add squash merge convention

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:06:43 +08:00
Dream Hunter
006ddf4aa4 docs: sync changelog with recent changes (#853)
- Add Status menu button feature entry to both CN/EN changelogs
- Add missing Improvements entries to EN changelog (IP lookup link, VitePress i18n fix)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:19:30 +08:00
Dream Hunter
f55e8c9818 feat: add configurable Status menu button (#851) 2026-03-03 12:58:49 +08:00
Dream Hunter
4ef4c0d938 Fix links in English README for consistency 2026-03-02 18:05:34 +08:00
Dream Hunter
f4255f33a1 Fix language links in README.md 2026-03-02 18:05:06 +08:00
Dream Hunter
a2d37b8183 docs: add private site password hint to all API docs (#850)
docs: add x-custom-auth private site password hint to all API docs

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 18:04:27 +08:00
Dream Hunter
9b7a80ef54 fix: add missing message_id index to DB init and migration (#849)
fix: add missing message_id index to DB_INIT_QUERIES and migration

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:34:26 +08:00
Dream Hunter
a5e5fceab5 fix: prevent account_settings KV.put on empty fromBlockList (#847)
* fix: avoid KV.put when fromBlockList is empty

* docs: update English changelog for account_settings fix
2026-03-02 00:27:24 +08:00
Dream Hunter
0df74ee5cc docs: improve SEO and redirect root to /zh/ (#846)
docs: improve SEO meta tags and redirect root to /zh/

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:23:43 +08:00
Dream Hunter
2b33d953fa docs: fix i18n language switch path error (#845)
docs: fix i18n language switch by using dual prefix locales

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:16:47 +08:00
Dream Hunter
1f969738f5 docs: add Windows .env file note for VITE_IS_TELEGRAM (#844) 2026-02-25 14:31:05 +08:00
Dream Hunter
4b378ca710 ci: optimize workflows with parallel jobs and conditional checks (#843)
* ci: optimize workflows with parallel jobs and conditional checks

* ci: add sync and tag triggers to frontend_pagefunction_deploy
2026-02-25 14:20:26 +08:00
Dream Hunter
bafd003cbd chore: upgrade dependencies (#842)
chore: upgrade dependencies across frontend, worker, pages and docs

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:05:12 +08:00
Dream Hunter
723e1fe75d feat: add ip.im link for source IP in admin account list (#841) 2026-02-25 13:59:53 +08:00
Dream Hunter
566c6536d1 docs: fix user API auth and add admin delete API docs (#836) 2026-02-16 15:49:26 +08:00
Dream Hunter
bde08b9d55 feat: add email regex validation for user registration (#835) 2026-02-16 12:40:20 +08:00
Dream Hunter
56351ed963 style: improve empty state display for inbox and sent box (#831)
- Add different messages based on mail count (empty vs select)
- Add semantic icons (InboxRound for inbox, SendRound for sent)
- Unify list container height to min-height: 60vh; max-height: 100vh
- Update CHANGELOG for v1.4.0

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 23:47:49 +08:00
Dream Hunter
9583f0e1c5 feat: upgrade version to v1.4.0 (#830) 2026-02-02 22:15:58 +08:00
Dream Hunter
898324777e docs: update CHANGELOG for v1.3.0 recent PRs (#828)
- Add OAuth2 SVG icon support (#825)
- Add async address activity update (#826)
- Add hide send mail UI when not configured (#827)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:41:56 +08:00
Dream Hunter
0f418d7e94 feat: hide send mail UI when not configured (#827)
- Add isSendMailEnabled and isAnySendMailEnabled functions in common.ts
- Return enableSendMail field in /open_api/settings
- Hide sendmail tab, sendbox tab, and reply button when send mail is not configured
- Check RESEND_TOKEN, SMTP_CONFIG, and SEND_MAIL binding per domain

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 23:37:51 +08:00
Dream Hunter
e4b6c82e92 perf: use waitUntil for async address activity updates (#826)
- Change updateAddressUpdatedAt to non-blocking async execution
- GET /api/mails, /api/settings, /user_api/settings no longer wait for DB update
- Improves response time for GET requests
- Also updates dependencies

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:20:39 +08:00
Dream Hunter
d367bc92b2 feat(oauth2): add SVG icon support for OAuth2 providers (#825)
- Add optional `icon` field to UserOauth2Settings type
- Include preset SVG icons for GitHub, Linux Do, and Authentik templates
- Render icons on OAuth2 login buttons
- Add icon configuration UI with preview in admin panel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:00:15 +08:00
Dream Hunter
f0da9289fc fix(user): enforce address count limit when anonymous creation disabled (#819) 2026-01-23 22:44:31 +08:00
Dream Hunter
decede7ed3 feat(oauth2): add email format transformation support (#818)
* feat(oauth2): add email format transformation support

- Add enableEmailFormat, userEmailFormat, userEmailReplace fields
- Support regex pattern matching and replacement template ($1, $2, etc.)
- Add Linux Do OAuth2 template with email format pre-configured
- Add input length limit (256 chars) to prevent ReDoS attacks
- Update admin UI with conditional display and tooltips
- Update documentation (zh/en) with configuration examples

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: update lock files and version

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: restore accessTokenFormat as optional field

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:30:44 +08:00
Dream Hunter
e4c96c9868 style: use softer blue for AI extraction info in dark mode (#817)
- Use Gmail's #A8C7FA color for AI extraction alert and tag in dark mode
- Update CHANGELOG.md and CHANGELOG_EN.md for v1.2.1

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:20:06 +08:00
Dream Hunter
2318e0f7e2 fix: scheduled task cleanup error - upgrade to v1.2.1 (#816)
fix: scheduled task cleanup error "e.get is not a function"

- Use optional chaining in i18n.getMessagesbyContext to safely access Context methods
- Update version to v1.2.1

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 20:05:06 +08:00
Dream Hunter
2cce3df213 ci: update PR Agent to qodo-ai/pr-agent@main (#811)
ci: update PR Agent to use qodo-ai/pr-agent@main

- Add issue_comment trigger for responding to PR comments
- Add contents: write permission
- Update to official qodo-ai/pr-agent@main action

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 21:27:00 +08:00
Dream Hunter
a38a31a407 docs: add AI extract content length limit (4000 chars) (#809)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 20:13:56 +08:00
Dream Hunter
276093f113 docs: add v1.2.0 new features documentation (#808)
- Add TG_ALLOW_USER_LANG variable to worker-vars.md (zh/en)
- Add SUBDOMAIN_FORWARD_ADDRESS_LIST with sourcePatterns docs (zh/en)
- Add /lang command and language switching docs to telegram.md (zh/en)
- Add TG_ALLOW_USER_LANG example to wrangler.toml.template

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:47:30 +08:00
Dream Hunter
36f8c4b3de fix: use lowercase repository name for container image (#807)
GitHub Container Registry requires lowercase image names. Use bash
parameter expansion to convert repository name to lowercase.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:23:02 +08:00
Dream Hunter
8964d4461d feat: add admin account page with logout and responsive address bar (#803)
- Add admin account tab to display current login method
- Support logout for admin password login only
- Show login method (password/user admin/disabled check)
- Improve address bar responsive layout with auto-wrap
- Update changelog for new features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:16:39 +08:00
Dream Hunter
a771446b9b Unify address selection UI (#801)
* feat: unify address selection UI

* docs: update changelog for address UI

* feat: restore user mailbox tab
2026-01-01 21:14:07 +08:00
Dream Hunter
50ab6756bd fix: remove invalid escape sequences in i18n placeholders (#800)
- Remove backslashes from source_patterns_placeholder in both en and zh
- Fix vue-i18n SyntaxError: 10 (invalid escape sequence)
- Change placeholder from 'e.g. @gmail\\.com$' to 'e.g. gmail.com'

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 02:13:32 +08:00
Dream Hunter
aee1f1942b fix: ensure emailForwardingList is always initialized (#799)
- Fix emailRuleSettings initialization to ensure emailForwardingList is always an array
- Prevent SyntaxError when adding new forwarding rules with incomplete backend data
- Use optional chaining to safely access emailRuleSettings fields

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 02:04:42 +08:00
Dream Hunter
3ebe22115a feat: add i18n support for backend API and Telegram bot (#797)
* feat: add i18n support for backend API and Telegram bot

- Add comprehensive i18n support for all backend API error messages (zh/en)
- Add /lang command for Telegram bot to set language preference
- Add bilingual command descriptions for Telegram bot
- Support per-user language preference stored in KV
- Global push uses DEFAULT_LANG, user push uses saved preference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: improve Telegram bot language preference feature

- Add internationalized message for disabled language feature
- Fix hardcoded English message in /lang command
- Optimize getTgMessages calls (reduce from 3 to 1 call)
- Remove verbose comments for better code clarity
- Add TgLangFeatureDisabledMsg to i18n (zh/en)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-31 01:42:41 +08:00
Dream Hunter
5e227d2b2d feat: add source address regex forwarding (#796)
feat: add source address regex forwarding for email rules

- Add sourcePatterns field to filter forwarding by sender address regex
- Support 'any' and 'all' match modes for multiple patterns
- Add ReDoS protection with 200 character limit
- Frontend validation for regex patterns
- Fully backward compatible with existing configurations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-29 17:15:24 +08:00
Dream Hunter
3b3968f3b4 feat: add address source tracking (source_meta field) (#794)
- Add source_meta field to address table for tracking creation source
- Web: records client IP address (with fallback to 'web:unknown')
- Telegram: records 'tg:{userId}'
- Admin: records 'admin'
- Add database migration with field existence check
- Add frontend display in admin Account page
- Backward compatible: fallback if field doesn't exist

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-28 13:44:52 +08:00
Hogum
499f65078b Add keep_vars option to wrangler.toml.template (#793) 2025-12-27 02:08:57 +08:00
Dream Hunter
0d63142bd7 docs: update English README and add English CHANGELOG (#792)
- Expand README_EN.md with comprehensive feature documentation
  - Add complete header with badges and repository info
  - Include detailed feature sections (Email Processing, User Management, Admin Features, etc.)
  - Add Technical Architecture section with system design details
  - Include Service Status Monitoring and Star History
  - Add Table of Contents for better navigation
  - Add Important Notes about Resend domain configuration

- Create CHANGELOG_EN.md with full translation
  - Translate all version entries from v1.2.0 to initial releases
  - Preserve technical details, issue references, and breaking changes
  - Maintain consistent formatting with Chinese version

- Add language switch links to both CHANGELOG files
  - Enable easy navigation between Chinese and English versions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 23:06:16 +08:00
Dream Hunter
16ce1bf4e0 Update README.md 2025-12-16 11:10:36 +08:00
Androw Smith
288eb38302 update readme (#789) 2025-12-16 11:06:21 +08:00
Dream Hunter
24366e2bff feat: improve mailbox UI spacing and email display (#788) 2025-12-15 13:35:03 +08:00
Dream Hunter
e5f62d4713 feat: optimize email filtering with frontend-only search (#787)
* feat: optimize email filtering with frontend-only search

- Remove backend keyword parameter from mail APIs (breaking change)
- Implement frontend filtering on current page (20-100 items)
- Add message_id database index for UPDATE performance
- Support desktop and mobile responsive layouts
- Update API documentation and CHANGELOG

BREAKING CHANGE: /admin/mails and /user_api/mails no longer accept keyword parameter

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: restore Mail ID query input in Index.vue

- Keep showMailIdQuery UI input for querying specific mail by ID
- Triggered when URL contains mail_id parameter

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-15 02:55:50 +08:00
Dream Hunter
1836f931ee fix: move useScript to top level for @unhead/vue v2 compatibility (#785)
Move useScript call outside onMounted to avoid context loss in async callbacks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 16:24:31 +08:00
Dream Hunter
0f2836eebb feat: upgrade version to v1.2.0 (#784)
* feat: upgrade version to v1.2.0

- Update version number to 1.2.0 in all package.json files
- Add v1.2.0 placeholder in CHANGELOG.md with custom SQL cleanup feature
- Upgrade dependencies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: update @unhead/vue import path for v2.x compatibility

Change import from '@unhead/vue' to '@unhead/vue/client' for createHead

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 00:10:20 +08:00
Dream Hunter
b933aef7d9 fix: escape @ symbol in vue-i18n to fix SyntaxError (#783)
fix: escape @ symbol in vue-i18n translation strings

The @ symbol is interpreted as linked message syntax in vue-i18n,
causing SyntaxError when parsing. Use {'@'} to escape it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 23:56:00 +08:00
Dream Hunter
fa72d7187f feat: v1.1.0 release with AI email extraction and security enhancements (#782)
fix: initialize customSqlCleanupList to empty array if undefined

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 23:45:23 +08:00
Dream Hunter
d15a4904a5 feat: add custom SQL cleanup for scheduled maintenance (#781)
- Add CustomSqlCleanup type to models
- Add validateCustomSql and executeCustomSqlCleanup functions
- Add SQL validation: DELETE only, single statement, max 1000 chars
- Integrate custom SQL cleanup with scheduled job
- Add frontend UI with tabs for basic/custom SQL cleanup
- Support i18n for English and Chinese

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-11 23:33:33 +08:00
Dream Hunter
a25199eb34 fix: exclude telegram routes from x-custom-auth check (#780)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:18:20 +08:00
Dream Hunter
7d485a7d0d docs: update CHANGELOG and README for v1.1.0 (#778)
docs: update CHANGELOG and README for v1.1.0 AI extraction feature

- Add AI email extraction feature description to CHANGELOG v1.1.0
- Update README.md with AI extraction feature in email processing section
- Update README_EN.md with AI extraction feature in key features
- Include verification code display fix in changelog
2025-12-06 16:46:31 +08:00
Dream Hunter
abe812666f fix: display auth_code result directly without result_text (#777)
- Ensure verification codes always show the raw result value
- Links continue to prefer result_text as display label
- Fixes display logic in both compact and full modes
2025-12-06 16:37:01 +08:00
Dream Hunter
dbb55d948f feat: add AI email extraction with Cloudflare Workers AI
Add AI-powered email content extraction feature using Cloudflare Workers AI to automatically identify and extract important information from emails including verification codes, authentication links, service links, and subscription links.

Features:
- AI extraction with priority-based logic (auth_code > auth_link > service_link > subscription_link > other_link)
- Admin allowlist configuration with wildcard support (*@example.com)
- Frontend display in both email list (compact) and detail view (full mode)
- Bilingual documentation (Chinese/English)
- Database migration: add metadata field to raw_mails (v0.0.3 -> v0.0.4)

Technical highlights:
- Proper regex escaping for wildcard pattern matching
- Content truncation to avoid AI token limits
- Error handling that won't affect email receiving
- JSON schema validation for AI responses
- Type-safe TypeScript implementation
- Vue I18n support with special character escaping

References:
- Inspired by Alle Project: https://github.com/bestruirui/Alle
- Uses Cloudflare Workers AI JSON Mode

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-06 16:28:19 +08:00
Dream Hunter
a2a9f9e25f fix: 修复自定义认证密码功能并更新版本号 (#772)
fix: 修复自定义认证密码功能的问题并更新版本号

- 更新版本号到 v1.1.0
- 整理 CHANGELOG,将修复信息合并到 v1.1.0 版本中

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-25 02:02:30 +08:00
Dream Hunter
793901d349 fix: 修复自定义认证密码功能异常 (#771)
fix: 修复自定义认证密码功能异常的问题

- 修复前端属性名错误 (openSettings.auth -> openSettings.needAuth)
- 修复 /open_api/settings 接口被自定义认证拦截的问题

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-23 23:31:23 +08:00
Dream Hunter
113f9ad66b feat: add empty address cleanup feature (#765)
* feat: add empty address cleanup feature

Add functionality to clean up email addresses that have never received any emails and were created more than N days ago.

Changes:
- Add emptyAddress cleanup type to backend cleanup logic
- Add enableEmptyAddressAutoCleanup and cleanEmptyAddressDays to CleanupSettings model
- Add scheduled task support for auto-cleanup of empty addresses
- Add UI controls in Maintenance page for manual and auto cleanup
- Add i18n support (English and Chinese translations)

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* chore: update dependencies

Update package.json and lock files across frontend, worker, pages, and vitepress-docs

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* docs: update CHANGELOG for empty address cleanup feature

Add entry for new maintenance page feature to clean up email addresses with no emails older than N days

🤖 Generated with [Claude Code](https://claude.ai/code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-13 17:57:35 +08:00
nightwhite
088bf3eefe fix: 修复 IMAP fetch 方法返回生成器导致的 TypeError (#764)
修复了在 K8s 环境中运行时出现的错误:
File "/usr/local/lib/python3.12/site-packages/twisted/mail/imap4.py", line 1685, in __cbManualSearch
    lastSequenceId = result and result[-1][0]
TypeError: 'generator' object is not subscriptable

问题原因:
- fetch 方法返回了生成器对象
- Twisted IMAP4 库在处理 SEARCH 命令时需要对结果进行索引访问
- 生成器不支持索引操作

解决方案:
- 将 fetch 方法的返回值从生成器改为列表
- 保持 fetchGenerator 方法的分批获取逻辑(batch_size=20)
- 确保内存占用可控的同时支持所有 IMAP 操作
2025-11-10 19:33:42 +08:00
Dream Hunter
024f9ba430 feat: upgrade version to v1.1.0 (#760)
- Update version number to 1.1.0 in all package.json files
- Add v1.1.0 placeholder in CHANGELOG.md

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 13:16:06 +08:00
Dream Hunter
b337a44e62 feat: add daily request limit and refactor access control (#759)
- Add daily request limit per IP in blacklist settings (1-1,000,000/day)
- Refactor access control logic: merge blacklist and rate limit checks
- Remove RATE_LIMIT_API_DAILY_REQUESTS env var, use database config instead
- Move x-custom-auth check earlier in middleware chain
- Add comprehensive English documentation (31 new guide pages)
- Improve code structure and error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-08 12:46:30 +08:00
Dream Hunter
eaeac8ebec feat: 添加浏览器指纹黑名单功能 (#757)
* feat: 添加浏览器指纹黑名单功能

- 前端集成 @fingerprintjs/fingerprintjs 库自动采集浏览器指纹
- 在所有 API 请求中通过 x-fingerprint header 传递指纹信息
- 将指纹黑名单集成到现有的 IP 黑名单功能中
- 支持精确匹配和正则表达式模式匹配指纹
- 在 App.vue mount 时预初始化指纹,避免首次请求延迟
- 使用 Vue 全局状态缓存指纹,避免重复生成
- 管理后台新增指纹黑名单配置,与 IP/ASN 黑名单统一管理
- 后端在限流 API 请求前检查指纹黑名单,返回 403 阻止访问

技术细节:
- 指纹生成时间:50-300ms(一次性)
- 缓存命中:<1ms
- 请求开销:~20 字节/请求
- 支持最多 1000 条指纹黑名单规则
- 完善的错误处理,失败时不阻塞正常请求

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* refactor: 优化浏览器指纹初始化逻辑

- 移除 App.vue 中的预初始化,改为在首次 API 调用时自动初始化
- 移除不必要的 clearFingerprintCache 函数
- 初始化失败时返回特殊值 'ERROR' 而非空字符串
- 失败值会被缓存,避免重复尝试失败

优势:
- 减少页面加载时的初始化开销
- 简化代码,去除不必要的函数
- 更清晰的错误标识

🤖 Generated with [Claude Code](https://claude.ai/code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-05 15:50:39 +08:00
Dream Hunter
7393519ba4 fix: ASN blacklist not working due to missing asnBlacklist field (#756)
- Fix getIpBlacklistSettings() to include asnBlacklist field in return value
- Add case-insensitive flag support for regex patterns in ASN matching
- Refactor IP blacklist check logic for better code organization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-03 23:30:24 +08:00
Dream Hunter
4ddc8e5c96 feat: add ASN organization blacklist for IP filtering (#755)
- Add asnBlacklist field to IpBlacklistSettings (optional)
- Create shared isBlacklisted() function for IP and ASN matching
- Add isAsnBlacklisted() function with case-insensitive matching
- Extend checkIpBlacklist() to also check ASN organizations
- Update admin API to validate and save ASN blacklist
- Add ASN blacklist input to admin UI (below IP blacklist)
- Support text matching and regex for ASN organization names
- ASN data from request.cf.asOrganization (Cloudflare)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-03 22:25:27 +08:00
Dream Hunter
8b7ddae4f6 feat: upgrade version to v1.0.7 (#754)
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
- feat: |Admin| 新增 RATE_LIMIT_API_DAILY_REQUESTS 配置,用于限制每日 API 请求次数
- fix: |Admin| IP 黑名单检查增加错误处理,提高系统稳定性

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-03 21:00:44 +08:00
Dream Hunter
be36967b80 feat: add IP blacklist feature for rate-limited APIs (#753) 2025-11-03 20:31:32 +08:00
Dream Hunter
fac249ed31 feat: add RATE_LIMIT_API_DAILY_REQUESTS (#752) 2025-11-03 02:46:16 +08:00
Dream Hunter
bfd7d6811e feat: add RATE_LIMIT_API_DAILY_REQUESTS (#751) 2025-11-03 02:36:38 +08:00
Dream Hunter
b5c229b6c4 feat: v1.0.6 (#743) 2025-10-12 18:24:36 +08:00
Dream Hunter
2728e9667b fix: 针对角色配置不同的绑定地址数量上限 (#742) 2025-10-12 18:21:22 +08:00
Dream Hunter
6109ab9e82 feat: add role-based address limit configuration (#741)
- Add RoleAddressConfig component in admin panel
- Implement role_address_config API endpoints (GET/POST)
- Add getMaxAddressCount function with validation chain
- Priority: role config > global settings
- Support editable table with clearable input
- Add extensible RoleConfig type for future fields
- Use context for current user, query DB for target user
- Optimize state management (remove redundant roleConfigsMap)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 18:12:36 +08:00
Dream Hunter
09a6cac8fe feat: upgrade dependencies (#740) 2025-10-12 13:47:40 +08:00
Dream Hunter
5f752c94f9 feat: add cleanup for unbound addresses feature (#739)
- Add unboundAddress cleanup type to backend cleanup logic
- Update CleanupSettings model with unbound address fields
- Add scheduled task for automatic unbound address cleanup
- Add UI controls in admin Maintenance panel for manual cleanup
- Add i18n support (en/zh) for unbound address cleanup labels
- Clean addresses not bound to any user created before n days

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-12 13:46:13 +08:00
Dream Hunter
a2f3634c7e feat: add multi-select batch operations for admin account management (#737)
- Add multi-select functionality with native Naive UI selection column
- Implement batch operations: delete, clear inbox, clear sent items
- Create reusable executeBatchOperation function to eliminate code duplication
- Add error recovery mechanism: failed items remain selected for retry
- Add progress modal with real-time percentage display
- Support smart skip logic: skip addresses with no mails/sent items
- Add i18n support for English and Chinese
- Update CHANGELOG.md with new features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-04 20:04:26 +08:00
Dream Hunter
b62a3cbc3e fix: require explicit 'true' value for debug mode in deployment (#735)
- Update GitHub Actions workflow to only enable debug mode when DEBUG_MODE secret is exactly 'true'
- Previously debug mode was enabled for any non-empty value, which could lead to accidental activation
- Change from `[ -n "$debug_mode" ]` to `[ "$debug_mode" = "true" ]` for precise control

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 19:14:46 +08:00
Dream Hunter
8edb75587e fix: code bugs (#734) 2025-09-26 19:05:08 +08:00
Dream Hunter
de48661d0d fix: code bugs (#733) 2025-09-26 19:02:57 +08:00
Dream Hunter
a905ba5f06 feat: implement address password authentication feature (#731)
* feat: implement address password authentication feature

- Add password field to address table for storing hashed passwords
- Implement address authentication APIs (login, change password)
- Add automatic password generation for new addresses
- Support password login alongside credential login in frontend
- Add password management in account settings and admin panel
- Add ENABLE_ADDRESS_PASSWORD environment variable for feature control
- Update documentation and i18n support
- Enhance security with SHA-256 password hashing

🤖 Generated with [Claude Code](https://claude.ai/code)

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

* feat: upgrade dependencies

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 14:52:05 +08:00
Dream Hunter
6ae90be3bf feat: support github action deploy telegram mini app frontend (#730) 2025-09-22 21:25:15 +08:00
Dream Hunter
5e24817de6 feat: upgrade version (#726) 2025-09-15 10:43:53 +08:00
Dream Hunter
732189482e feat: db schema index update (#725)
* feat: db schema index update

* feat: upgrade dependencies
2025-09-15 10:41:14 +08:00
Dream Hunter
2bbde15f53 feat: add clear inbox and sent items functionality (#720)
- Add clear inbox/sent items APIs for users and admins
- Implement ENABLE_USER_DELETE_EMAIL permission checks
- Fix multilingual support for success messages
- Update Vue to 3.5.21 and Wrangler to 4.34.0
- Add UI components for clearing email data in account settings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-05 20:43:28 +08:00
Dream Hunter
37cf0776b5 feat: enhance webhook security with configurable allow list (#719)
- Add enableAllowList flag to webhook settings for flexible access control
- Update frontend UI with toggle switch and improved user experience
- Maintain backward compatibility with default allow-all behavior
- Add input validation hints and better form controls across admin panels

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-05 17:24:30 +08:00
Dream Hunter
3fbace871c feat: add var DISABLE_CUSTOM_ADDRESS_NAME and CREATE_ADDRESS_DEFAULT_… (#717)
* feat: add var DISABLE_CUSTOM_ADDRESS_NAME and CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST

* fix: enhance input validation with trim() for address creation

- Add trim() handling in newAddress() function to prevent whitespace issues
- Add trim() handling for address prefixes to ensure consistent formatting
- Add trim() handling in Telegram API address parsing for robustness
- Prevents edge cases with whitespace-only or padded input strings

🤖 Generated with [Claude Code](https://claude.ai/code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-04 21:04:42 +08:00
Dream Hunter
648e9f7adf feat: add simplemode button at index (#714) 2025-08-27 20:22:52 +08:00
305 changed files with 31284 additions and 8004 deletions

1
.agents/skills Symbolic link
View File

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

View File

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

View File

@@ -0,0 +1,3 @@
config.json
__pycache__/
*.py[cod]

View File

@@ -0,0 +1,30 @@
---
name: cf-temp-mail-release-notify
description: Announce a cloudflare_temp_email GitHub release to the project's Telegram channel topic. Use when the user asks to notify/announce/broadcast a release to Telegram, push release notes to the channel, or send a release to the topic after running cf-temp-mail-release. Posts bilingual (中文 + English) changelog excerpts plus the release URL.
---
# Release Notify Workflow
Post an existing GitHub release's notes to the project's Telegram channel topic.
## Prerequisites
- `config.json` exists in this skill directory with `token`, `chat_id`, `message_thread_id` (gitignored, never commit).
- `gh` CLI authenticated.
- `uv` installed (`brew install uv` / `curl -LsSf https://astral.sh/uv/install.sh | sh`). Script uses PEP 723 inline metadata; `uv` auto-installs deps.
## Steps
1. **Resolve tag**: If the user didn't give one, use the latest release: `gh release list --limit 1 --json tagName --jq '.[0].tagName'`.
2. **Run the script**:
```bash
uv run scripts/send_release_to_telegram.py vX.Y.Z
```
The script fetches the release via `gh`, splits the body into zh/en sections, strips PR collapsibles and the cache-clearing link, truncates to fit Telegram's 4096-char limit, and posts to the configured `chat_id` + `message_thread_id`.
3. **Verify**: The script prints `ok: message_id=<id>` on success. Report the message id.
## Notes
- Message uses `parse_mode: MarkdownV2`; all content is escaped (via `md_escape`) to avoid parse errors on reserved chars `_ * [ ] ( ) ~ \` > # + - = | { } . !`.
- Only the zh/en changelog sections are posted. PRs list and the cache-clearing discussion link are stripped to keep the message concise.
- For very long release bodies, zh and en are each truncated to ~half of the 3500-char body budget.

View File

@@ -0,0 +1,5 @@
{
"token": "<telegram bot token>",
"chat_id": "@cloudflare_temp_email",
"message_thread_id": 82
}

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["httpx>=0.27"]
# ///
"""Send a cloudflare_temp_email release announcement to a Telegram channel topic.
Usage:
uv run scripts/send_release_to_telegram.py <tag>
Reads skill config from ../config.json (relative to this script):
{
"token": "...",
"chat_id": "@channel_or_-100...",
"message_thread_id": 82
}
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
import httpx
TG_API = "https://api.telegram.org"
TG_HARD_LIMIT = 4096
BODY_BUDGET = 3500 # leave room for header + footer
EN_MARKER_RE = re.compile(r"<details>\s*<summary>English</summary>", re.IGNORECASE)
MDV2_ESCAPE_RE = re.compile(r"([_*\[\]()~`>#+\-=|{}.!\\])")
MDV2_CODE_ESCAPE_RE = re.compile(r"([`\\])")
MD_INLINE_RE = re.compile(r"\*\*(.+?)\*\*|`([^`]+)`")
MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
def die(msg: str) -> None:
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def md_escape(text: str) -> str:
"""Escape all MarkdownV2 reserved characters."""
return MDV2_ESCAPE_RE.sub(r"\\\1", text)
def md_render(text: str) -> str:
"""Convert source Markdown (changelog) to Telegram MarkdownV2.
Handles:
- `### Heading` -> bold line
- `**bold**` -> `*bold*`
- `` `code` `` -> `` `code` `` (only ` and \\ escaped inside)
- everything else: literal text with MDV2 specials escaped
"""
out: list[str] = []
for raw in text.splitlines():
m = MD_HEADING_RE.match(raw)
if m:
out.append(f"*{md_escape(m.group(2).strip())}*")
continue
segments: list[str] = []
last = 0
for im in MD_INLINE_RE.finditer(raw):
segments.append(md_escape(raw[last:im.start()]))
if im.group(1) is not None:
segments.append(f"*{md_escape(im.group(1))}*")
else:
segments.append(f"`{MDV2_CODE_ESCAPE_RE.sub(r'\\\\\1', im.group(2))}`")
last = im.end()
segments.append(md_escape(raw[last:]))
out.append("".join(segments))
return "\n".join(out)
def load_config() -> dict:
cfg_path = Path(__file__).resolve().parent.parent / "config.json"
if not cfg_path.exists():
die(f"config missing: {cfg_path}")
try:
cfg = json.loads(cfg_path.read_text())
except json.JSONDecodeError as e:
die(f"config.json is not valid JSON: {e}")
for k in ("token", "chat_id", "message_thread_id"):
if k not in cfg:
die(f"config.json missing key: {k}")
return cfg
def fetch_release(tag: str) -> dict:
out = subprocess.run(
["gh", "release", "view", tag, "--json", "tagName,name,body,url"],
capture_output=True, text=True, check=False,
)
if out.returncode != 0:
die(f"gh release view failed: {out.stderr.strip()}")
return json.loads(out.stdout)
def extract_sections(body: str) -> tuple[str, str]:
m = EN_MARKER_RE.search(body)
if not m:
return body.strip(), ""
zh = body[: m.start()]
rest = body[m.end():]
close = rest.find("</details>")
if close < 0:
die("malformed release body: missing </details> after English marker")
en = rest[:close]
return zh.strip(), en.strip()
def strip_noise(text: str) -> str:
"""Drop PR collapsibles, cache-clearing link, and Full Changelog line."""
lines = text.splitlines()
out: list[str] = []
depth = 0
for line in lines:
stripped = line.strip()
if stripped.startswith("<details>"):
depth += 1
continue
if stripped.startswith("</details>"):
depth = max(0, depth - 1)
continue
if depth > 0:
continue
if "discussions/487" in stripped:
continue
if stripped.startswith("**Full Changelog**"):
continue
out.append(line)
result: list[str] = []
blanks = 0
for line in out:
if not line.strip():
blanks += 1
if blanks <= 1:
result.append(line)
else:
blanks = 0
result.append(line)
return "\n".join(result).strip()
def truncate(text: str, limit: int) -> str:
if len(text) <= limit:
return text
return text[: limit - 3].rstrip() + "..."
def _budget(zh: str, en: str, total: int) -> tuple[int, int]:
"""Split budget between zh and en based on actual length. Short side keeps full, long side absorbs the rest."""
if not en:
return total, 0
if len(zh) + len(en) <= total:
return len(zh), len(en)
half = total // 2
if len(zh) <= half:
return len(zh), total - len(zh)
if len(en) <= half:
return total - len(en), len(en)
return half, total - half
def build_message(tag: str, name: str, url: str, body: str) -> str:
zh, en = extract_sections(body)
zh = strip_noise(zh)
en = strip_noise(en)
zh_limit, en_limit = _budget(zh, en, BODY_BUDGET)
zh = truncate(zh, zh_limit)
en = truncate(en, en_limit) if en else ""
title = md_escape(name or tag)
header = f"🚀 *{title} 已发布 / Released*"
parts = [header, "", md_render(zh)]
if en:
parts.extend(["", "__English__", "", md_render(en)])
parts.extend(["", f"🔗 {md_escape(url)}"])
return "\n".join(parts)
def send(cfg: dict, text: str) -> None:
payload = {
"chat_id": cfg["chat_id"],
"message_thread_id": cfg["message_thread_id"],
"text": text,
"parse_mode": "MarkdownV2",
"disable_web_page_preview": False,
}
try:
resp = httpx.post(
f"{TG_API}/bot{cfg['token']}/sendMessage",
json=payload,
timeout=30,
)
except httpx.HTTPError as e:
die(f"Telegram network error: {e}")
try:
data = resp.json()
except ValueError:
die(f"Telegram API returned non-JSON ({resp.status_code}): {resp.text[:200]!r}")
if resp.status_code != 200 or not data.get("ok"):
die(f"Telegram API rejected ({resp.status_code}): {data}")
print(f"ok: message_id={data['result'].get('message_id')}")
def main() -> None:
if len(sys.argv) != 2:
die("usage: send_release_to_telegram.py <tag>")
cfg = load_config()
rel = fetch_release(sys.argv[1])
text = build_message(rel["tagName"], rel.get("name", ""), rel["url"], rel.get("body", ""))
send(cfg, text)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
---
name: cf-temp-mail-release
description: Create a GitHub release for cloudflare_temp_email project. Use when the user asks to create a release, publish a version, tag a release, or make a new release. Reads CHANGELOG.md for release content, collects merged PRs via `gh` CLI, and creates a properly formatted GitHub release.
---
# Release Workflow
## Steps
1. **Read version**: Get current version from `worker/package.json` (`"version"` field) and the latest release tag via `gh release list --limit 1`.
2. **Read CHANGELOG**: Read `CHANGELOG.md` for the current version section (e.g. `## v1.4.0(main)`). Verify content matches `CHANGELOG_EN.md`. If entries are missing from either file, notify the user.
3. **Collect PRs**: Get the last release tag timestamp, then filter merged PRs by time:
```bash
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
SINCE="$(git show -s --format=%cI "$TAG")"
gh pr list --state merged --search "is:pr is:merged merged:>$SINCE base:main" --json number,title,author --limit 200
```
Sort by PR number ascending.
4. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules:
- Write release body in **bilingual format**: Chinese sections first (from `CHANGELOG.md`), then wrap the English sections (from `CHANGELOG_EN.md`) in `<details><summary>English</summary>...</details>`.
- Copy changelog sections verbatim from both files. Omit empty sections.
- Wrap PRs list in `<details><summary>PRs</summary>...</details>`.
- Always include the cache-clearing discussion link.
- End with `**Full Changelog**` comparison link.
5. **Create release**:
- Write body to a temp file (e.g. `/tmp/release-notes.md`)
- Run: `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes.md --target main`
6. **Verify**: Confirm the release URL and ask the user to review.

View File

@@ -0,0 +1,42 @@
# Release Notes Template
Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本段落提取:
```markdown
## What's Changed
### Features
- feat: |模块| 描述
### Bug Fixes
- fix: |模块| 描述
### Testing
- test: |模块| 描述
### Improvements
- style/refactor/perf/docs: |模块| 描述
### [更新或者部署网页不生效请如图勾选清理缓存](https://github.com/dreamhunter2333/cloudflare_temp_email/discussions/487)
<details>
<summary>PRs</summary>
* PR title by @author in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/NUMBER
</details>
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/vOLD...vNEW
```
## Notes
- Sections without entries should be omitted
- PRs section uses `<details>` to collapse by default
- PRs are sorted by PR number ascending
- The cache clearing discussion link is always included
- Release title and tag use format `vX.Y.Z`

View File

@@ -0,0 +1,43 @@
---
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/.
---
# Upgrade Dependencies
Upgrade npm dependencies for the cloudflare_temp_email sub-packages.
## How to run
Execute the project-root script:
```bash
bash scripts/update-dependencies.sh
```
The script runs the following in order:
| Directory | Commands |
|-----------|----------|
| `frontend/` | `pnpm up` + `pnpm add -D wrangler@latest` |
| `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` |
Note: `vitepress-docs/` uses `--latest` (crosses semver ranges); other packages upgrade within ranges only.
## Post-upgrade checklist
1. Inspect `git diff` on `package.json` / `pnpm-lock.yaml` 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`.
## Do NOT
- Do not manually `pnpm add` each package instead of running the script.
- Do not run `pnpm deploy` locally — deployments go through GitHub Actions.
- Do not update CHANGELOG for routine dep bumps unless the user explicitly requests it.

View File

@@ -0,0 +1,56 @@
---
name: cf-temp-mail-version-upgrade
description: Upgrade the project version number. Use when the user asks to bump the version, upgrade the version, or prepare a new release version. Supports major, minor, and patch upgrades.
---
# Version Upgrade
Upgrade the version number of the cloudflare_temp_email project.
## Files to modify
1. `frontend/package.json``version` field
2. `worker/package.json``version` field
3. `worker/src/constants.ts``VERSION` constant (format: `VERSION: 'v' + '1.4.0'`)
4. `pages/package.json``version` field
5. `vitepress-docs/package.json``version` field
6. `CHANGELOG.md` — add new version placeholder
7. `CHANGELOG_EN.md` — add new version placeholder (English)
## Upgrade workflow
1. Read `frontend/package.json` to get the current version.
2. Compute the new version based on the upgrade type:
- major: 1.3.0 → 2.0.0
- minor: 1.3.0 → 1.4.0
- patch: 1.3.0 → 1.3.1
3. Update the `version` field in every `package.json` listed above.
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`.
## CHANGELOG format
In `CHANGELOG.md`, insert before the existing `## v{OLD_VERSION}(main)` line (i.e. right after the closing `</p>` of the language-switch link):
```markdown
## v{VERSION}(main)
### Features
### Bug Fixes
### Improvements
```
`CHANGELOG_EN.md` uses the same format.
## Commit message format
```
feat: upgrade version to v{VERSION}
- Update version number to {VERSION} in all package.json files
- Add v{VERSION} placeholder in CHANGELOG.md
```

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
**/node_modules/
.git/
vitepress-docs/
smtp_proxy_server/
mail-parser-wasm/
db/
**/.wrangler/
**/dist/
**/test-results/
**/playwright-report/
.DS_Store

View File

@@ -1,16 +1,14 @@
diff --git a/worker/src/common.ts b/worker/src/common.ts
index bd9bcc9..e7e2748 100644
index 9b758f0..e2150b5 100644
--- a/worker/src/common.ts
+++ b/worker/src/common.ts
@@ -273,23 +273,23 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
}
const raw_mail = parsedEmailContext.rawEmail;
// TODO: WASM parse email
// NOTE: WASM parse email
- // try {
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
+ try {
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
-
- // const parsedEmail = parse_message_wrapper(raw_mail);
- // parsedEmailContext.parsedEmail = {
- // sender: parsedEmail.sender || "",
@@ -20,11 +18,20 @@ index bd9bcc9..e7e2748 100644
- // (header) => ({ key: header.key, value: header.value })
- // ) || [],
- // html: parsedEmail.body_html || "",
- // attachments: (parsedEmail.attachments || []).map(att => ({
- // filename: att.filename || "attachment",
- // mimeType: att.content_type || "application/octet-stream",
- // content: att.content,
- // disposition: "attachment",
- // })),
- // };
- // return parsedEmailContext.parsedEmail;
- // } catch (e) {
- // console.error("Failed use mail-parser-wasm-worker to parse email", e);
- // }
+ try {
+ const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
+
+ const parsedEmail = parse_message_wrapper(raw_mail);
+ parsedEmailContext.parsedEmail = {
+ sender: parsedEmail.sender || "",
@@ -34,6 +41,12 @@ index bd9bcc9..e7e2748 100644
+ (header) => ({ key: header.key, value: header.value })
+ ) || [],
+ html: parsedEmail.body_html || "",
+ attachments: (parsedEmail.attachments || []).map(att => ({
+ filename: att.filename || "attachment",
+ mimeType: att.content_type || "application/octet-stream",
+ content: att.content,
+ disposition: "attachment",
+ })),
+ };
+ return parsedEmailContext.parsedEmail;
+ } catch (e) {

View File

@@ -16,34 +16,45 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v5
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: Deploy Backend for ${{ github.ref_name }}
run: |
export use_worker_assets=${{ secrets.USE_WORKER_ASSETS }}
export use_worker_assets_with_telegram=${{ secrets.USE_WORKER_ASSETS_WITH_TELEGRAM }}
if [ -n "$use_worker_assets" ]; then
cd frontend/
pnpm install --no-frozen-lockfile
pnpm build:pages
if [ -n "$use_worker_assets_with_telegram" ]; then
echo "Building with telegram pages"
pnpm build:telegram:pages
else
echo "Building with normal pages"
pnpm build:pages
fi
cd ..
fi
export debug_mode=${{ secrets.DEBUG_MODE }}
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
cd worker/
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
# ✅ 修复核心:使用环境变量写入,避免 Shell 解析特殊字符
printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml
pnpm install --no-frozen-lockfile
if [ -n "$use_mail_wasm_parser" ]; then
@@ -53,17 +64,20 @@ jobs:
echo "Applied mail-parser-wasm-worker patch"
fi
if [ -n "$debug_mode" ]; then
if [ "$debug_mode" = "true" ]; then
pnpm run deploy
else
output=$(pnpm run deploy 2>&1)
if [ $? -ne 0 ]; then
code=$?
echo "Command failed with exit code $code"
exit $code
if pnpm run deploy >/dev/null 2>&1; then
echo "Deploy succeeded"
else
code=$?
echo "Command failed with exit code $code"
exit "$code"
fi
fi
echo "Deployed for tag ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# ✅ 将 secret 映射到环境变量中
WRANGLER_TOML_CONTENT: ${{ secrets.BACKEND_TOML }}

View File

@@ -1,64 +1,50 @@
name: Deploy Docs
on:
push:
paths:
- "vitepress-docs/**"
tags:
- "*"
workflow_run:
workflows: ["Tag Build CI"]
types:
- completed
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v5
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: check github release done
run: |
for ((attempt=1; attempt<=10; attempt++)); do
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
echo "frontend.zip found."
break
else
if [ $attempt -eq 10 ]; then
echo "Exceeded maximum retries. frontend.zip not found."
else
echo "frontend.zip not found. Retrying in 30 seconds..."
sleep 30
fi
fi
done
- name: Deploy Docs for ${{github.ref_name}}
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
if [[ ${{github.ref}} == refs/tags/* ]]; then
export TAG_NAME=${{github.ref_name}}
else
export TAG_NAME=$(git describe --tags --abbrev=0)
fi
echo "Deploying docs for tag $TAG_NAME"
pnpm run deploy
- name: Deploy Docs
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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')
echo "Deploying docs for tag $TAG_NAME"
export TAG_NAME
pnpm run deploy

34
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: End-to-End Tests
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Run E2E tests
run: |
cd e2e
docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner
- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: |
e2e/test-results/
e2e/playwright-report/
retention-days: 30
- name: Cleanup
if: always()
run: |
cd e2e
docker compose down -v

View File

@@ -10,55 +10,76 @@ on:
workflow_dispatch:
jobs:
deploy:
deploy-frontend:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v5
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}
if: ${{ env.FRONTEND_NAME != '' }}
run: |
cd frontend/
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
export project_name=${{ secrets.FRONTEND_NAME }}
pnpm install --no-frozen-lockfile
export frontend_branch=${{ secrets.FRONTEND_BRANCH }}
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
if [ -n "$frontend_branch" ]; then
echo "Deploying branch $frontend_branch"
pnpm run deploy:actions --project-name=$project_name --branch $frontend_branch
pnpm run deploy:actions --project-name=$FRONTEND_NAME --branch $frontend_branch
else
echo "Deploying branch production"
pnpm run deploy --project-name=$project_name
pnpm run deploy --project-name=$FRONTEND_NAME
fi
echo "Deploying production for ${{ github.ref_name }}"
echo "Deployed for tag ${{ github.ref_name }}"
export tg_mini_app_project_name=${{ secrets.TG_FRONTEND_NAME }}
if [ -n "$tg_mini_app_project_name" ]; then
echo "Deploying telegram mini app $tg_mini_app_project_name"
if [ -n "$frontend_branch" ]; then
echo "Deploying telegram mini app branch $frontend_branch"
pnpm run deploy:actions:telegram --project-name=$tg_mini_app_project_name --branch $frontend_branch
else
echo "Deploying telegram mini app branch production"
pnpm run deploy:telegram --project-name=$tg_mini_app_project_name
fi
echo "Deployed telegram mini app for ${{ github.ref_name }}"
fi
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FRONTEND_NAME: ${{ secrets.FRONTEND_NAME }}
deploy-telegram-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@v5
name: Install pnpm
with:
version: 10
run_install: false
- name: Deploy Telegram Frontend for ${{ github.ref_name }}
if: ${{ env.TG_FRONTEND_NAME != '' }}
run: |
cd frontend/
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
pnpm install --no-frozen-lockfile
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
if [ -n "$frontend_branch" ]; then
echo "Deploying telegram mini app branch $frontend_branch"
pnpm run deploy:actions:telegram --project-name=$TG_FRONTEND_NAME --branch $frontend_branch
else
echo "Deploying telegram mini app branch production"
pnpm run deploy:telegram --project-name=$TG_FRONTEND_NAME
fi
echo "Deployed telegram mini app for ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TG_FRONTEND_NAME: ${{ secrets.TG_FRONTEND_NAME }}

View File

@@ -1,27 +1,48 @@
name: Deploy Frontend with page function
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
tags:
- "*"
workflow_dispatch:
jobs:
deploy:
check:
runs-on: ubuntu-latest
outputs:
has_config: ${{ steps.check.outputs.has_config }}
steps:
- name: Check PAGE_TOML
id: check
run: |
if [ -n "$PAGE_TOML" ]; then
echo "has_config=true" >> $GITHUB_OUTPUT
else
echo "has_config=false" >> $GITHUB_OUTPUT
fi
env:
PAGE_TOML: ${{ secrets.PAGE_TOML }}
deploy:
needs: check
if: ${{ needs.check.outputs.has_config == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v5
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}
@@ -33,7 +54,7 @@ jobs:
echo '${{ secrets.PAGE_TOML }}' > wrangler.toml
pnpm install --no-frozen-lockfile
pnpm run deploy
echo "Deploying prodcution for ${{ github.ref_name }}"
echo "Deploying production for ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -3,6 +3,7 @@ name: Codium PR Agent
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
jobs:
pr_agent_job:
if: ${{ github.event.sender.type != 'Bot' }}
@@ -10,16 +11,17 @@ jobs:
permissions:
issues: write
pull-requests: write
contents: write
name: Run pr agent on every pull request, respond to user comments
steps:
- name: PR Agent action step
id: pragent
uses: docker://codiumai/pr-agent:0.29-github_action
uses: qodo-ai/pr-agent@main
env:
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
CONFIG.MODEL: "gpt-4o"
CONFIG.MODEL_TURBO: "gpt-4o"
CONFIG.MODEL: "gpt-5.4-nano"
CONFIG.MODEL_TURBO: "gpt-5.4-nano"
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -21,28 +21,31 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Set lowercase repository name
run: echo "REPO_LOWER=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- name: Build and push Docker images
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: ./smtp_proxy_server
file: ./smtp_proxy_server/dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY }}/${{ env.REPO_LOWER }}/${{ env.IMAGE_NAME }}:latest

View File

@@ -12,14 +12,7 @@ jobs:
if: ${{ github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
with:
upstream_sync_repo: dreamhunter2333/cloudflare_temp_email
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
test_mode: false
run: gh repo sync ${{ github.repository }} --source dreamhunter2333/cloudflare_temp_email --branch main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -6,24 +6,21 @@ on:
- "*"
jobs:
build:
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v5
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: Build Frontend
@@ -32,12 +29,58 @@ jobs:
- name: Zip Frontend dist
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: frontend
path: frontend/frontend.zip
build-telegram-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@v5
name: Install pnpm
with:
version: 10
run_install: false
- name: Build Telegram Frontend
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:telegram:release
- name: Zip Telegram Frontend dist
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: telegram-frontend
path: frontend/telegram-frontend.zip
build-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@v5
name: Install pnpm
with:
version: 10
run_install: false
- name: cp wrangler.toml
run: cd worker && cp wrangler.toml.template wrangler.toml
@@ -57,11 +100,39 @@ jobs:
pnpm build
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
- name: Upload to Release
uses: softprops/action-gh-release@v2
- name: Upload worker.js
uses: actions/upload-artifact@v7
with:
files: |
frontend/frontend.zip
frontend/telegram-frontend.zip
worker/worker.js
worker/worker-with-wasm-mail-parser.zip
name: worker-js
path: worker/worker.js
- name: Upload wasm worker
uses: actions/upload-artifact@v7
with:
name: worker-wasm
path: worker/worker-with-wasm-mail-parser.zip
release:
runs-on: ubuntu-latest
needs: [build-frontend, build-telegram-frontend, build-backend]
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Upload Assets to Release
run: |
gh release upload "${{ github.ref_name }}" \
artifacts/frontend/frontend.zip \
artifacts/telegram-frontend/telegram-frontend.zip \
artifacts/worker-js/worker.js \
artifacts/worker-wasm/worker-with-wasm-mail-parser.zip \
--clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored
View File

@@ -137,3 +137,8 @@ dist
wrangler.toml
.dev.vars
pnpm-lock.yaml
# E2E test artifacts
e2e/test-results/
e2e/playwright-report/
e2e/.e2e-pids

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

View File

@@ -1,7 +1,241 @@
<!-- markdownlint-disable-file MD004 MD024 MD034 MD036 -->
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
# CHANGE LOG
## main(v1.0.4)
<p align="center">
<a href="CHANGELOG.md">中文</a> |
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.9.0(main)
### Features
- feat: |Frontend| 将邮箱地址凭证弹窗升级为“地址凭证与连接方式”,复用普通用户与 admin 创建邮箱结果弹窗;支持通过 `ENABLE_AGENT_EMAIL_INFO` 展示 AI Agent 接入信息,并通过 `SMTP_IMAP_PROXY_CONFIG` 展示 SMTP/IMAP 客户端连接信息
### Bug Fixes
- fix: |Admin| 管理员重置邮箱地址密码时改为前端 SHA-256 后提交,后端只接受并存储哈希值,避免该接口继续接收明文密码
- fix: |Address| 管理员邮箱地址列表与用户绑定地址列表不再返回已存储的地址密码哈希值,避免列表接口暴露敏感字段
### Improvements
## v1.8.0
### Features
- feat: |Frontend| 前端新增 6 国语言支持(`zh` / `en` / `es` / `pt-BR` / `ja` / `de`),默认语言保持为 `zh`;无 locale 前缀路由(如 `/``/user`)默认使用中文渲染,同时会记录浏览器语言作为语言偏好。用户手动切换后会持久化语言偏好,并保持当前页面路径、查询参数与 canonical locale URL 一致
- feat: |API| 新增服务端解析邮件接口 `/api/parsed_mails``/api/parsed_mail/:id`,直接返回 `sender` / `subject` / `text` / `html` / `attachments` 元信息(复用 `commonParseMail`AI agent 侧不再需要引入 MIME 解析器
- feat: |Skill| 新增仓库内置只读 skill `cf-temp-mail-agent-mail``skills/cf-temp-mail-agent-mail/`),让 OpenClaw / Codex / Cursor 等 AI agent 凭用户提供的 Address JWT + API 地址读取邮箱、轮询验证码,绕开创建邮箱时的 Turnstile 人机验证;可通过 `npx degit dreamhunter2333/cloudflare_temp_email/skills/cf-temp-mail-agent-mail` 安装
- docs: |文档| 新增"AI Agent 使用邮箱"文档(`guide/feature/agent-email`),说明 `parsed_mail` API 用法,并在 parsed API 不可用时给出对齐前端的 `mail-parser-wasm` + `postal-mime` 本地解析回退方案
- docs: |文档| 在 `quick-start` / `worker-vars` / `email-routing` 三个入口文档(中英文)显式补充"域名是部署前提条件"提示,强调需先在 Cloudflare 启用 Email Routing 并下发邮件 DNS 记录、Worker 部署后再绑定 Catch-all子域名需单独启用避免用户在没有可用域名时直接开始部署却收不到邮件issue #1004
- docs: |部署排障| 优化近期 issue 暴露的 UI 部署与升级排障文档:补充 `nodejs_compat`、D1 绑定名必须为 `DB``/open_api/settings` 校验、后端 API 地址填写、Cloudflare 安全挑战导致 `Network Error`、D1 容量上限与 Cron Trigger 自动清理、GitHub OAuth 公开邮箱、admin 管理口令与用户账号区别、随机二级域名 API 需传 `enableRandomSubdomain` 等说明;同时将帮助/FAQ 菜单移动到核心配置之后,提升可见性
- docs: |文档| 补充重新创建旧邮箱提示地址已存在时的处理方式,并完善 GitHub Actions 自动更新配合 Page Functions 转发后端请求的 workflow 说明issues #947 #654
- docs: |OAuth2| 补充 GitHub 私密邮箱登录配置,说明可使用 `https://api.github.com/user/emails`、JSONPath 邮箱字段和 `user:email` scope 获取主邮箱issue #655
### Bug Fixes
- fix: |Frontend| 收窄地址管理相关弹窗宽度,并让地址表格在弹窗内部横向滚动,避免多地址场景撑宽弹窗
- fix: |Frontend| 修复 `/open_api/settings` 未返回 `domains` 数组时前端设置初始化直接调用 `map()``undefined` 错误的问题,统一按空数组兜底处理
- fix: |Frontend| 修复前端在 `jwt` / `auth` / `adminAuth` 等 localStorage 凭据为空字符串、字面量 `"undefined"` 或包含换行/控制符时,请求构造的 `Authorization` 等头部抛出 `Invalid character in header content` 导致前端所有接口报错的问题issue #1000)。新增 `safeHeaderValue` / `safeBearerHeader` 工具,对全部认证头做 RFC 7230 校验,不安全的值直接跳过该头部,让 worker 走标准 401 而不是请求级崩溃
- fix: |Frontend| 修复多语言菜单在移动端顶部显示语言与版本按钮导致 Header 横向拥挤或溢出的问题,移动端仅保留菜单按钮并将语言/版本入口放入抽屉
### Improvements
- refactor: |Worker| 拆分 `mails_api/index.ts``admin_api/index.ts`,入口只负责挂路由,业务拆到各自的 `*_api.ts` 文件(`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`),保持路径与行为不变
## v1.7.0
### Breaking Changes
- breaking: |发信| `SEND_MAIL` 的语义已从“仅用于 `verifiedAddressList` 命中的兼容发信路径”调整为“常规兜底发信通道”。如果实例已绑定 `SEND_MAIL` 且未配置 Resend/SMTP升级后未命中 `verifiedAddressList` 的收件人也会直接通过 Cloudflare binding 发出,发信行为与成本路径会发生变化
### Features
- feat: |发信| 推荐使用 Cloudflare `send_email` binding 作为默认发信通道,已 onboard Email Routing 的域名未配置 Resend/SMTP 时自动走 binding 发至任意地址Workers Paid 每月含 3000 封,超出 $0.35/1000 封);历史 `verifiedAddressList` / Resend / SMTP 配置完全兼容(#964
### Bug Fixes
- fix: |发送邮件| 当 `DEFAULT_SEND_BALANCE > 0` 时,首次访问发信设置或调用发信接口会为缺少 `address_sender` 记录的地址自动初始化默认额度(`ON CONFLICT DO NOTHING`用户不再需要先手动申请发信权限已存在的记录包括管理员禁用或手动设置的行一律保持原样runtime 不会覆盖(#925 #985
- fix: |用户侧收件箱| 修复 `ENABLE_USER_DELETE_EMAIL` 关闭时用户中心仍显示删除按钮且仍可通过 `/user_api/mails/:id` 删除邮件的问题(#978
- fix: |Address| 创建邮箱时统一将配置的前缀转为小写,避免生成包含大写前缀的地址;历史数据需用户自行迁移为小写(#930
### Improvements
## v1.6.0
### Features
- feat: |Admin| IP 黑名单设置新增 **IP 白名单(严格模式)**:启用后仅允许匹配白名单的 IP 访问受限流保护的 API创建邮箱、发送邮件、外部发送邮件、用户注册、验证码校验其他所有 IP 一律拒绝(#920
- feat: |Address| 支持最大地址数量设置为 `0` 表示无限制(#968
### Bug Fixes
- fix: |Admin| 修复 `/admin/address``/admin/users` 在使用完整邮箱query 长度超过 50 字节)作为搜索条件时报错 `D1_ERROR: LIKE or GLOB pattern too complex` 的问题,长查询自动改用 `instr()` 绕开 D1 的 LIKE pattern 长度限制(#956
### Improvements
- docs: |发送邮件 API| 明确 `/api/send_mail``/external/api/send_mail` 两个端点的认证方式差异,补充"地址 JWT"概念说明(#922
- docs: |Worker 变量| `JWT_SECRET` 补充生成方式说明(`openssl rand -hex 32`#932
- docs: |CLI 部署| `routes` 自定义域名配置增加用途说明(#932
- docs: |Admin API| `/admin/new_address` 返回值文档补充 `address_id` 字段(#912
- docs: |Admin| 补充管理后台账号列表排序功能说明(#918
- docs: |Pages 部署| 补充 SPA 模式说明,避免刷新页面或直接访问子路径时 404#813
- docs: |侧边栏| 重组文档侧边栏结构,拆分为"核心配置"、"通知与集成"、"高级功能"、"管理后台"等分组
- docs: |FAQ| 大幅扩充常见问题,新增 SPA 404、发信余额、SMTP_CONFIG 配置、邮件客户端登录等高频问题(#919, #925, #839, #715, #921, #609
- docs: |发送邮件| 增强 SMTP_CONFIG 字段说明和多域名示例,新增发信余额机制说明
- docs: |Email Routing| 补充子域名需单独启用 Email Routing 的说明,避免仅在一级域名开启导致子域收不到邮件(#969
## v1.5.0
### Features
- feat: |Admin| 管理后台账号列表支持按列排序ID、名称、创建时间、更新时间、邮件数量、发送数量搜索时自动重置分页到第1页#918
- feat: |Admin API| `/admin/new_address` 接口返回值新增 `address_id` 字段,避免创建后需再次查询地址 ID#912
- feat: |创建邮箱| 新增 `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 开关,并支持在管理后台单独控制创建邮箱 API 的子域名后缀匹配;开启后允许 `foo.example.com` 匹配基础域名 `example.com`
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894
- feat: |邮件存储| 支持通过 `ENABLE_MAIL_GZIP` 变量启用 Gzip 压缩邮件存储(#823
- 启用前需先执行数据库迁移:`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema`,或调用接口 `POST /admin/db_migration`
- 新邮件写入 `raw_blob`,兼容读取 `raw` / `raw_blob`;压缩与解压会增加 CPU 开销,建议付费 Worker Plan 再开启
### Bug Fixes
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
### Testing
- test: |E2E| 新增创建邮箱子域名匹配测试,覆盖默认精确匹配、后台开启后生效,以及 env=false 的硬禁用优先级
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
### Docs
- docs: |创建邮箱| 补充创建邮箱 API / Worker 变量 / 子域名文档,说明“直接指定子域名”和“随机子域名”两种能力的区别
- docs: |API| 新增地址 JWT 与用户 JWT 的区分说明,避免混淆两种认证方式;调整文档菜单结构,将 API 接口文档归类到独立分组(#910
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
### Improvements
## v1.4.0
### Features
- feat: |用户注册| 新增用户注册邮箱正则校验功能,管理员可配置邮箱格式验证规则
- feat: |前端| 新增可配置的 Status 菜单按钮,通过 `STATUS_URL` 环境变量配置状态监控页面链接
- feat: |SMTP| SMTP 代理服务支持 STARTTLS通过 `smtp_tls_cert``smtp_tls_key` 环境变量配置
- feat: |Webhook| Webhook 设置页面新增预设模板下拉菜单,支持 Message Pusher、Bark、ntfy 一键填充配置
### Bug Fixes
- fix: |Telegram| 修复 admin 用户通过 Telegram MiniApp 查看邮件时报 `Auth date expired` 的问题,支持 admin 密码认证查看邮件
- fix: |Admin API| 修复 `/admin/account_settings` 在未配置 KV 且 `fromBlockList` 为空时触发 `Cannot read properties of undefined (reading 'put')` 的问题
- fix: |数据库| 修复 `DB_INIT_QUERIES` 缺少 `idx_raw_mails_message_id` 索引导致 `UPDATE raw_mails ... WHERE message_id = ?` 全表扫描的问题,同步 `schema.sql` 与初始化代码,新增 v0.0.6 迁移逻辑
- fix: |文档| 修复 User Mail API 文档中错误使用 `x-admin-auth` 的问题,改为正确的 `x-user-token`
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义
- fix: |API| 修复 `requset_send_mail_access` API 路径拼写错误,改为 `request_send_mail_access`
### Testing
- test: |E2E| 新增 Docker 化端到端测试环境Playwright + Mailpit`cd e2e && npm test` 一条命令运行
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
### Improvements
- style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标
- feat: |后台管理| 邮箱地址列表来源IP添加 ip.im 查询链接点击可快速查看IP信息
- docs: |文档| 修复 VitePress 中英文切换路径错误,改用双前缀 locale 配置
- feat: |IMAP 代理| 重构 IMAP 服务端拆分为独立模块HTTP 客户端、邮箱、消息),使用 `deferToThread` 异步 HTTP 避免阻塞 Twisted reactor使用后端 `id` 作为稳定 UID新增 STARTTLS 支持、LRU 消息缓存、session 级 flags 管理、SEARCH 命令支持、JWT 凭证和地址+密码双登录方式,新增完整测试套件
- fix: |IMAP 代理| 修复 `getHeaders()` 过滤逻辑、`store()` 崩溃问题
- fix: |邮件解析| 修复 `parse_email.py` 中使用私有属性 `_payload` 导致编码错误的问题,改用 `get_payload(decode=True)` 正确解码邮件体
## v1.3.0
### Features
- feat: |OAuth2| 新增 OAuth2 邮箱格式转换功能,支持通过正则表达式转换第三方登录返回的邮箱格式(如将 `user@domain` 转换为 `user@custom.domain`
- feat: |OAuth2| 新增 OAuth2 提供商 SVG 图标支持,管理员可为登录按钮配置自定义图标,预置 GitHub、Linux Do、Authentik 模板图标
- feat: |发送邮件| 未配置发送邮件功能时自动隐藏发送邮件 tab、发件箱 tab 和回复按钮
### Bug Fixes
- fix: |用户地址| 修复禁止匿名创建时,已登录用户地址数量限制检查失效的问题,新增公共函数 `isAddressCountLimitReached` 统一处理地址数量限制逻辑
### Improvements
- refactor: |代码重构| 提取地址数量限制检查为公共函数,优化代码复用性
- perf: |性能优化| GET 请求中的地址活动时间更新改为异步执行,使用 `waitUntil` 不阻塞响应
## v1.2.1
### Bug Fixes
- fix: |定时任务| 修复定时任务清理报错 `e.get is not a function`,使用可选链安全访问 Context 方法
### Improvements
- style: |AI 提取| 暗色模式下 AI 提取信息使用更柔和的蓝色 (#A8C7FA),减少视觉疲劳
## v1.2.0
### Breaking Changes
- |数据库| 新增 `source_meta` 字段,需执行 `db/2025-12-27-source-meta.sql` 更新数据库或到 admin 维护页面点击数据库更新按钮
### Features
- feat: |Admin| 新增管理员账号页面,显示当前登录方式并支持退出登录(仅限密码登录方式)
- fix: |GitHub Actions| 修复容器镜像名需要全部小写的问题
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
- feat: |地址来源| 新增地址来源追踪功能记录地址创建来源Web 记录 IPTelegram 记录用户 IDAdmin 后台标记)
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
- feat: |Telegram| 机器人支持中英文切换,新增 `/lang` 命令设置语言偏好
## v1.1.0
- feat: |AI 提取| 增加 AI 邮件识别功能,使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- 支持优先级提取:验证码 > 认证链接 > 服务链接 > 订阅链接 > 其他链接
- 管理员可配置地址白名单(支持通配符,如 `*@example.com`
- 前端列表和详情页展示提取结果
- 需要配置 `ENABLE_AI_EMAIL_EXTRACT` 环境变量和 AI 绑定
- 需要执行 `db/2025-12-06-metadata.sql` 文件中的 SQL 更新 `D1` 数据库 或者到 admin维护页面点击数据库更新按钮
- feat: |Admin| 维护页面增加清理 n 天前空邮件的邮箱地址功能
- fix: 修复自定义认证密码功能异常的问题 (前端属性名错误 & /open_api 接口被拦截)
## v1.0.7
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
## v1.0.6
- feat: |DB| update db schema add index
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
## v1.0.5
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
- feat: |UI| 主页增加进入极简模式按钮
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
## v1.0.4
- feat: |UI| 优化极简模式主页, 增加全部邮件页面功能(删除/下载/附件/...), 可在 `外观` 中切换
- feat: admin 账号设置页面增加 `邮件转发规则` 配置

755
CHANGELOG_EN.md Normal file
View File

@@ -0,0 +1,755 @@
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
# CHANGE LOG
<p align="center">
<a href="CHANGELOG.md">中文</a> |
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.9.0(main)
### Features
- 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`
### 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
### Improvements
## v1.8.0
### Features
- feat: |Frontend| Add six-language frontend support (`zh` / `en` / `es` / `pt-BR` / `ja` / `de`), keep `zh` as the default locale; locale-unprefixed routes (for example `/` and `/user`) render in Chinese by default while still recording browser language as the stored preference. Explicit locale switches are persisted, and the current route, query string, and canonical locale URL stay in sync during switching
- feat: |API| Add server-side parsed-mail endpoints `/api/parsed_mails` and `/api/parsed_mail/:id` that return `sender` / `subject` / `text` / `html` / `attachments` metadata directly (reuses `commonParseMail`), so AI agents no longer need a client-side MIME parser
- feat: |Skill| Bundle a read-only skill `cf-temp-mail-agent-mail` (`skills/cf-temp-mail-agent-mail/`) so AI agents like OpenClaw / Codex / Cursor can consume a mailbox with a user-supplied Address JWT + API base URL — list mails, poll verification codes, etc. — sidestepping the Turnstile challenge required to create a mailbox. Install via `npx degit dreamhunter2333/cloudflare_temp_email/skills/cf-temp-mail-agent-mail`
- docs: |Docs| Add "AI Agent Mailbox Usage" doc (`guide/feature/agent-email`) covering the `parsed_mail` API and a local-parse fallback using `mail-parser-wasm` + `postal-mime` (mirrors the frontend) when parsed endpoints are unavailable
- docs: |Docs| Make "a domain is a hard prerequisite" explicit at the top of `quick-start`, `worker-vars`, and `email-routing` (zh + en), spelling out that Cloudflare Email Routing must be enabled with email DNS records provisioned before deployment, the Catch-all rule must be bound after the Worker is deployed, and subdomains do not inherit the parent domain's Email Routing — so users no longer start deploying without a usable domain and end up unable to receive mail (issue #1004)
- docs: |Deployment troubleshooting| Improve docs for recent UI-deployment and upgrade issues: document `nodejs_compat`, the required uppercase `DB` D1 binding, `/open_api/settings` verification, backend API URL entry, Cloudflare security challenges causing `Network Error`, D1 size limits and Cron Trigger cleanup, GitHub OAuth public email requirements, the difference between admin passwords and user accounts, and the `enableRandomSubdomain` API flag; move the Help/FAQ menu directly after Core Configuration so it is easier to find
- docs: |Docs| Document how to handle the "address already exists" case when recreating an old mailbox, and clarify the GitHub Actions workflow for automatic updates with Page Functions forwarding backend requests (issues #947 #654)
- docs: |OAuth2| Document GitHub private-email login configuration using `https://api.github.com/user/emails`, a JSONPath email field, and the `user:email` scope to read the primary email (issue #655)
### Bug Fixes
- fix: |Frontend| Narrow address-management modal widths and keep address tables horizontally scrollable inside the modal to prevent multi-address lists from stretching the dialog
- fix: |Frontend| Fix the frontend settings bootstrap throwing an `undefined` error when `/open_api/settings` does not return a `domains` array by normalizing the field to an empty array before mapping it
- fix: |Frontend| Fix every API call crashing client-side with `Invalid character in header content ["Authorization"]` when stale localStorage credentials (`jwt` / `auth` / `adminAuth` / `userJwt` / `access_token`) are empty, the literal string `"undefined"`, or contain a stray newline or other control character (issue #1000). Adds `safeHeaderValue` / `safeBearerHeader` helpers that validate every auth header against RFC 7230 and omit the header entirely when unsafe, so the worker returns a clean 401 instead of the request being rejected by axios/undici
- fix: |Frontend| Fix the multilingual header on mobile by keeping only the menu button in the top bar and moving language/version actions into the drawer to avoid horizontal crowding or overflow
### Improvements
- refactor: |Worker| Split `mails_api/index.ts` and `admin_api/index.ts` so the index files only wire routes. Business logic moved into dedicated `*_api.ts` files (`mails_crud.ts` / `new_address.ts` / `parsed_mail_api.ts` / `address_api.ts` / `address_sender_api.ts` / `sendbox_api.ts` / `statistics_api.ts` / `account_settings_api.ts`). Paths and behavior unchanged
## v1.7.0
### Breaking Changes
- breaking: |send mail| `SEND_MAIL` semantics changed from a verified-address-only compatibility path to a normal fallback send channel. If an instance already binds `SEND_MAIL` and does not configure Resend/SMTP, recipients outside `verifiedAddressList` will now also be sent through the Cloudflare binding after upgrade, changing runtime behavior and cost routing
### Features
- feat: |send mail| Recommend Cloudflare `send_email` binding as the default send channel. Domains onboarded to Email Routing without Resend/SMTP now automatically use the binding to send to arbitrary addresses (Workers Paid includes 3,000 msgs/month, $0.35/1000 beyond); existing `verifiedAddressList` / Resend / SMTP configurations remain fully compatible (#964)
### Bug Fixes
- fix: |Send Mail| Auto-initialize the default send balance for addresses that have no `address_sender` row yet when `DEFAULT_SEND_BALANCE > 0`, on the first send-settings read or send API call (`ON CONFLICT DO NOTHING`). Existing rows — including admin-disabled or admin-edited ones — are never overwritten by the runtime path, so users no longer need to manually request send permission first (#925 #985)
- fix: |User Mailbox| Fix an issue where the user center still showed delete actions and could still delete mail via `/user_api/mails/:id` when `ENABLE_USER_DELETE_EMAIL` was disabled (#978)
- fix: |Address| Lowercase configured prefixes when creating addresses to avoid generating mixed-case mailbox names; existing data must be migrated to lowercase manually by the user (#930)
### Improvements
## v1.6.0
### Features
- feat: |Admin| Add **IP Whitelist (strict mode)** to IP blacklist settings: when enabled, ONLY whitelisted IPs can access rate-limited APIs (create address, send mail, external send mail, user register, verify code); all other IPs are denied (#920)
- feat: |Address| Support setting max address count to `0` for unlimited (#968)
### Bug Fixes
- fix: |Admin| Fix `D1_ERROR: LIKE or GLOB pattern too complex` on `/admin/address` and `/admin/users` when searching by full email address (query length pushes the LIKE pattern over D1's 50-byte limit). Long queries now fall back to `instr()` to bypass the LIKE pattern length cap (#956)
### Improvements
- docs: |Send Mail API| Clarify authentication differences between `/api/send_mail` and `/external/api/send_mail`, add "Address JWT" concept explanation (#922)
- docs: |Worker Variables| Add generation instructions for `JWT_SECRET` (`openssl rand -hex 32`) (#932)
- docs: |CLI Deployment| Add usage explanation for `routes` custom domain configuration (#932)
- docs: |Admin API| Add `address_id` field to `/admin/new_address` response documentation (#912)
- docs: |Admin| Add account list sorting feature documentation (#918)
- docs: |Pages Deployment| Add SPA mode instructions to avoid 404 when refreshing or accessing sub-paths directly (#813)
- docs: |Sidebar| Restructure documentation sidebar into "Core Configuration", "Notifications & Integrations", "Advanced Features", "Admin Console" groups
- docs: |FAQ| Significantly expand FAQ with SPA 404, send balance, SMTP_CONFIG, mail client login and more (#919, #925, #839, #715, #921, #609)
- docs: |Email Sending| Enhance SMTP_CONFIG field reference and multi-domain examples, add send balance mechanism documentation
- docs: |Email Routing| Note that subdomains require Email Routing to be enabled separately; enabling it only on the apex domain does not cover subdomains (#969)
## v1.5.0
### Features
- feat: |Admin| Admin account list now supports column sorting (ID, name, created at, updated at, mail count, send count), search automatically resets pagination to page 1 (#918)
- feat: |Admin API| `/admin/new_address` endpoint now returns `address_id` field, avoiding additional query after address creation (#912)
- feat: |Create Address| Add `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` switch and an admin-panel toggle for suffix-based subdomain matching in create-address APIs; when enabled, `foo.example.com` can match base domain `example.com`
- feat: |Auto Reply| Add regex matching support for sender filter using `/pattern/` syntax (e.g. `/@example\.com$/`), backward compatible with prefix matching
- feat: |Turnstile| Add global Turnstile CAPTCHA for all login forms via `ENABLE_GLOBAL_TURNSTILE_CHECK` env var (#767)
- feat: |Telegram| Support sending email attachments in Telegram push (50MB per file limit), multiple attachments sent via `sendMediaGroup`, controlled by `ENABLE_TG_PUSH_ATTACHMENT` env var (#894)
- feat: |Mail Storage| Support enabling gzip-compressed email storage via `ENABLE_MAIL_GZIP` variable (#823)
- Run database migration before enabling it: `Admin -> Quick Setup -> Database -> Migrate Database`, or call `POST /admin/db_migration`
- New emails are stored in `raw_blob` and reads stay compatible with `raw` / `raw_blob`; compression and decompression add CPU overhead, so a paid Worker plan is recommended
### Bug Fixes
- fix: |Auto Reply| Fix auto-reply not triggering when `source_prefix` is empty string (#459), empty value now correctly matches all senders
- fix: |OAuth2| Fix OAuth2 login callback failure on Android via browser and other mobile browsers due to sessionStorage loss during redirect, add localStorage fallback (#900)
- fix: |IMAP| Fix nested reply email mojibake, Gmail empty Content-Type header parsing failure, missing Date header, and locale-dependent date formatting issues
### Testing
- test: |E2E| Add create-address subdomain matching tests covering default exact-match behavior, admin-enabled matching, and env=false hard-disable precedence
- test: |E2E| Add auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
### Docs
- docs: |Create Address| Update create-address API, worker variables, and subdomain docs to clarify the difference between explicitly specified subdomains and random subdomains
- docs: |API| Add clarification between Address JWT and User JWT to avoid confusion; reorganize documentation menu structure with dedicated API Endpoints section (#910)
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
- feat: |Webhook| Add Telegram Bot, WeChat Work, Discord preset templates to frontend webhook settings
### Improvements
## v1.4.0
### Features
- feat: |User Registration| Add email regex validation for user registration, admins can configure email format validation rules
- feat: |Frontend| Add configurable Status menu button via `STATUS_URL` environment variable for status monitoring page link
- feat: |SMTP| Add STARTTLS support for SMTP proxy server via `smtp_tls_cert` and `smtp_tls_key` environment variables
- feat: |Webhook| Add preset templates dropdown to Webhook settings page, supporting one-click fill for Message Pusher, Bark, and ntfy
### Bug Fixes
- fix: |Telegram| Fix admin users unable to view emails via Telegram MiniApp due to `Auth date expired` error, support admin password auth for viewing emails
- fix: |Admin API| Fix `/admin/account_settings` throwing `Cannot read properties of undefined (reading 'put')` when KV is not configured and `fromBlockList` is empty
- fix: |Database| Fix missing `idx_raw_mails_message_id` index in `DB_INIT_QUERIES` causing full table scan on `UPDATE raw_mails ... WHERE message_id = ?`, sync `schema.sql` with init code, add v0.0.6 migration
- fix: |Docs| Fix User Mail API documentation incorrectly using `x-admin-auth`, changed to correct `x-user-token`
- fix: |Frontend| Fix email content text being unreadable in dark theme, improve dark mode styles for plain text mail and Shadow DOM rendering
- docs: |Docs| Add Admin API documentation for delete mail, delete address, clear inbox, and clear sent items
- fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text
- fix: |Security| Fix XSS vulnerability in reply/forward mail content, sanitize HTML with DOMPurify whitelist and escape plain text
- fix: |API| Fix typo in `requset_send_mail_access` API path, renamed to `request_send_mail_access`
### Testing
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
### Improvements
- style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons
- feat: |Admin| Add ip.im lookup link for source IP in address list, click to quickly view IP information
- docs: |Docs| Fix VitePress i18n language switch path error, use dual-prefix locale configuration
- feat: |IMAP Proxy| Refactor IMAP server into separate modules (HTTP client, mailbox, message), use `deferToThread` for async HTTP to avoid blocking Twisted reactor, use backend `id` as stable UID, add STARTTLS support, LRU message cache, session-local flags management, SEARCH command support, JWT credential and address+password dual login methods, and comprehensive test suite
- fix: |IMAP Proxy| Fix `getHeaders()` filtering and `store()` crash
- fix: |Email Parser| Fix `parse_email.py` using private `_payload` attribute causing encoding errors, use `get_payload(decode=True)` for proper email body decoding
## v1.3.0
### Features
- feat: |OAuth2| Add email format transformation support for OAuth2, allowing regex-based email format conversion from third-party login providers (e.g., transform `user@domain` to `user@custom.domain`)
- feat: |OAuth2| Add SVG icon support for OAuth2 providers, admins can configure custom icons for login buttons, preset icons for GitHub, Linux Do, Authentik templates
- feat: |Send Mail| Auto-hide sendmail tab, sendbox tab, and reply button when send mail is not configured
### Bug Fixes
- fix: |User Address| Fix address count limit check failure when anonymous creation is disabled for logged-in users, add public function `isAddressCountLimitReached` to unify address count limit logic
### Improvements
- refactor: |Code Refactoring| Extract address count limit check as a public function to improve code reusability
- perf: |Performance| Change address activity time update in GET requests to async execution using `waitUntil`, non-blocking response
## v1.2.1
### Bug Fixes
- fix: |Scheduled Tasks| Fix scheduled task cleanup error `e.get is not a function`, use optional chaining for safe access to Context methods
### Improvements
- style: |AI Extraction| Use softer blue color (#A8C7FA) for AI extraction info in dark mode to reduce eye strain
## v1.2.0
### Breaking Changes
- |Database| Add `source_meta` field, need to execute `db/2025-12-27-source-meta.sql` to update database or click database update button on admin maintenance page
### Features
- feat: |Admin| Add admin account page, display current login method and support logout (password login only)
- fix: |GitHub Actions| Fix container image name must be lowercase
- feat: |Email Forwarding| Add source address regex forwarding, filter by sender address, fully backward compatible
- feat: |Address Source| Add address source tracking feature, record address creation source (Web records IP, Telegram records user ID, Admin panel marked)
- feat: |Email Filtering| Remove backend keyword parameter, switch to frontend filtering of current page emails, optimize query performance
- feat: |Frontend| Unify address switching into a dropdown component, support switching in simple mode, add address management entry on the homepage
- feat: |Database| Add index for `message_id` field to optimize email update operations, need to execute `db/2025-12-15-message-id-index.sql` to update database
- feat: |Admin| Add custom SQL cleanup feature to maintenance page, support scheduled task execution of custom cleanup statements
- feat: |i18n| Backend API error messages now fully support Chinese and English internationalization
- feat: |Telegram| Bot supports Chinese/English switching, add `/lang` command to set language preference
## v1.1.0
- feat: |AI Extraction| Add AI email recognition feature, use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
- Support priority extraction: verification codes > authentication links > service links > subscription links > other links
- Admin can configure address whitelist (supports wildcards, e.g. `*@example.com`)
- Frontend list and detail pages display extraction results
- Need to configure `ENABLE_AI_EMAIL_EXTRACT` environment variable and AI binding
- Need to execute SQL in `db/2025-12-06-metadata.sql` file to update `D1` database or click database update button on admin maintenance page
- feat: |Admin| Add feature to cleanup addresses with empty mailboxes older than n days on maintenance page
- fix: Fix custom authentication password function issue (frontend property name error & /open_api interface blocked)
## v1.0.7
- feat: |Admin| Add IP blacklist feature for limiting high-frequency API access
- feat: |Admin| Add ASN organization blacklist feature, support filtering requests based on ASN organization name (supports text matching and regex)
- feat: |Admin| Add browser fingerprint blacklist feature, support filtering requests based on browser fingerprint (supports exact matching and regex)
## v1.0.6
- feat: |DB| Update db schema add index
- feat: |Address Password| Add address password login feature, enabled via `ENABLE_ADDRESS_PASSWORD` configuration, need to execute SQL in `db/2025-09-23-patch.sql` file to update `D1` database
- fix: |GitHub Actions| Fix debug mode configuration, only enable debug mode when DEBUG_MODE is 'true'
- feat: |Admin| Account management page adds multi-select batch operations (batch delete, batch clear inbox, batch clear outbox)
- feat: |Admin| Maintenance page adds feature to cleanup unbound user addresses
- feat: Support configuring different bound address quantity limits for different roles, configurable in admin page
## v1.0.5
- feat: Add `DISABLE_CUSTOM_ADDRESS_NAME` configuration: disable custom email address name feature
- feat: Add `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` configuration: prioritize first domain when creating addresses
- feat: |UI| Add button to enter minimalist mode on homepage
- feat: |Webhook| Add whitelist switch feature, support flexible access control
## v1.0.4
- feat: |UI| Optimize minimalist mode homepage, add all emails page functionality (delete/download/attachments/...), switchable in `Appearance`
- feat: Admin account settings page adds `Email Forwarding Rules` configuration
- feat: Admin account settings page adds `Reject Unknown Address Emails` configuration
- feat: Email page adds Previous/Next buttons
## v1.0.3
- fix: Fix github actions deployment issue
- feat: telegram /new when domain not specified, use random address
## v1.0.2
- fix: Fix oauth2 login failure issue
## v1.0.1
- feat: |UI| Add minimalist mode homepage, switchable in `Appearance`
- fix: Fix oauth2 login default role not taking effect issue
## v1.0.0
- fix: |UI| Fix User inbox viewing, when address not selected, keyword query not working
- fix: Fix auto cleanup task, time 0 not taking effect issue
- feat: Cleanup feature adds cleanup of addresses created n days ago, cleanup of addresses inactive for n days
- fix: |IMAP Proxy| Fix IMAP Proxy server unable to view new emails issue
## v0.10.0
- feat: Support User inbox viewing, `/user_api/mails` interface, support `address` and `keyword` filtering
- fix: Fix Oauth2 login token retrieval, some Oauth2 require `redirect_uri` parameter issue
- feat: When user accesses webpage, if `user token` expires within 7 days, auto refresh
- feat: Add db initialization feature to admin portal
- feat: Add `ALWAYS_SHOW_ANNOUNCEMENT` variable to configure whether to always show announcements
## v0.9.1
- feat: |UI| Support google ads
- feat: |UI| Use shadow DOM to prevent style pollution
- feat: |UI| Support URL jwt parameter auto-login to mailbox, jwt parameter overrides browser jwt
- fix: |CleanUP| Fix cleanup emails when cleanup time exceeds 30 days error bug
- feat: Admin user management page: add user address viewing feature
- feat: | S3 Attachments| Add S3 attachment deletion feature
- feat: | Admin API| Add admin bind user and address api
- feat: | Oauth2 | When Oauth2 gets user info, support `JSONPATH` expressions
## v0.9.0
- feat: | Worker | Support multi-language
- feat: | Worker | `NO_LIMIT_SEND_ROLE` configuration supports multiple roles, comma separated
- feat: | Actions | Add `worker-with-wasm-mail-parser.zip` in build to support UI deployment with `wasm` worker
## v0.8.7
- fix: |UI| Fix mobile device date display issue
- feat: |Worker| Support sending emails via `SMTP`, using [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
## v0.8.6
- feat: |UI| Announcements support html format
- feat: |UI| `COPYRIGHT` supports html format
- feat: |Doc| Optimize deployment documentation, supplement `Github Actions Deployment Documentation`, add `Worker Variable Description`
## v0.8.5
- feat: |mail-parser-wasm-worker| Fix `deprecated` parameter warning when calling `initSync` function
- feat: rpc headers convert & typo (#559)
- fix: telegram mail page use iframe show email (#561)
- feat: |Worker| Add `REMOVE_ALL_ATTACHMENT` and `REMOVE_EXCEED_SIZE_ATTACHMENT` for removing email attachments, due to parsing emails some information will be lost, such as images.
## v0.8.4
- fix: |UI| Fix admin portal delete call api error when no recipient email
- feat: |Telegram Bot| Add telegram bot cleanup invalid address credentials command
- feat: Add worker configuration `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` to disable anonymous user email creation, only allow logged-in users to create email addresses
- feat: Add worker configuration `ENABLE_ANOTHER_WORKER` and `ANOTHER_WORKER_LIST`, for calling other worker rpc interfaces (#547)
- feat: |UI| Auto refresh configuration saved to browser, configurable refresh interval
- feat: Spam detection adds check-when-exists list `JUNK_MAIL_CHECK_LIST` configuration
- feat: | Worker | Add `ParsedEmailContext` class for caching parsed email content, reduce parsing times
- feat: |Github Action| Worker deployment adds `DEBUG_MODE` output logging, `BACKEND_USE_MAIL_WASM_PARSER` configuration for whether to use wasm to parse emails
## v0.8.3
- feat: |Github Action| Add auto update and deploy feature
- feat: |UI| Admin user settings, support oauth2 configuration deletion
- feat: Add spam detection must-pass list `JUNK_MAIL_FORCE_PASS_LIST` configuration
## v0.8.2
- fix: |Doc| Fix some documentation errors
- fix: |Github Action| Fix frontend deployment branch error issue
- feat: Admin send email feature
- feat: Admin backend, account configuration page adds unlimited send email address list
## v0.8.1
- feat: |Doc| Update UI installation documentation
- feat: |UI| Hide mailbox account ID from users
- feat: |UI| Add `Forward` button to email detail page
## v0.8.0
- feat: |UI| Random address generation doesn't exceed max length
- feat: |UI| Email time display in browser timezone, can switch to display UTC time in settings
- feat: Support transferring emails to other users
## v0.7.6
### Breaking Changes
UI deployment worker needs to click Settings -> Runtime, modify Compatibility flags, add `nodejs_compat`
![worker-runtime](vitepress-docs/docs/public/ui_install/worker-runtime.png)
### Changes
- feat: Support pre-setting bot info to reduce telegram callback latency (#441)
- feat: Add telegram mini app build archive
- feat: Add whether to enable spam check `ENABLE_CHECK_JUNK_MAIL` configuration
## v0.7.5
- fix: Fix `name` validation check
## v0.7.4
- feat: UI list page adds minimum width
- fix: Fix `name` validation check
- fix: Fix `DEFAULT_DOMAINS` configuration empty not taking effect issue
## v0.7.3
- feat: Worker adds `ADDRESS_CHECK_REGEX`, address name regex, only for checking, matching will pass check
- fix: UI fix login page tab active icon misalignment
- fix: UI fix admin page refresh popup password input issue
- feat: Support `OAuth2` login, can login via `Github` `Authentik` and other third parties, see details [OAuth2 Third-party Login](https://temp-mail-docs.awsl.uk/en/guide/feature/user-oauth2.html)
## v0.7.2
### Breaking Changes
`webhook` structure adds `enabled` field, existing configurations need to be re-enabled and saved on the page.
### Changes
- fix: Worker adds `NO_LIMIT_SEND_ROLE` configuration, loading failure issue
- feat: Worker adds `# ADDRESS_REGEX = "[^a-z.0-9]"` configuration, regex for replacing illegal symbols, if not set, defaults to [^a-z0-9], use with caution, some symbols may cause receiving issues
- feat: Worker optimizes webhook logic, supports admin configuring global webhook, adds `message pusher` integration example
## v0.7.1
- fix: Fix user role loading failure issue
- feat: Admin account settings adds source email address blacklist configuration
## v0.7.0
### Breaking Changes
DB changes: Add user `passkey` table, need to execute `db/2024-08-10-patch.sql` to update `D1` database
### Changes
- Docs: Update new-address-api.md (#360)
- feat: Worker adds `ADMIN_USER_ROLE` configuration, for configuring admin user role, users with this role can access admin management page (#363)
- feat: Worker adds `DISABLE_SHOW_GITHUB` configuration, for configuring whether to show github link
- feat: Worker adds `NO_LIMIT_SEND_ROLE` configuration, for configuring roles that can send unlimited emails
- feat: User adds `passkey` login method, for user login, no password required
- feat: Worker adds `DISABLE_ADMIN_PASSWORD_CHECK` configuration, for configuring whether to disable admin console password check, if your site is only privately accessible, you can disable the check
## v0.6.1
- pages github actions && fix cleanup emails days 0 not taking effect by @tqjason (#355)
- fix: imap proxy server doesn't support password by @dreamhunter2333 (#356)
- worker adds `ANNOUNCEMENT` configuration, for configuring announcement info by @dreamhunter2333 (#357)
- fix: telegram bot create new address defaults to first domain by @dreamhunter2333 (#358)
## v0.6.0
### Breaking Changes
DB changes: Add user role table, need to execute `db/2024-07-14-patch.sql` to update `D1` database
### Changes
Worker configuration file adds `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, see documentation [worker configuration](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
- Remove `apiV1` related code and related database tables
- Update `admin/statistics` api, add user statistics info
- Update address rules, only allow lowercase+numbers, for historical addresses `lowercase` processing will be performed when querying emails
- Add user role feature, `admin` can set user roles (currently can configure domain and prefix for each role)
- Admin page search optimization, enter key auto search, input content auto trim
## v0.5.4
- Click logo 5 times to enter admin page
- Fix 401 cannot redirect to login page (admin and site authentication)
## v0.5.3
- Fix some bugs in smtp imap proxy server
- Improve user/admin delete inbox/outbox functionality
- Admin can delete send permission records
- Add Chinese email alias configuration `DOMAIN_LABELS` [documentation](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
- Remove `mail channels` related code
- github actions adds `FRONTEND_BRANCH` variable to specify deployment branch (#324)
## v0.5.1
- Add `mail-parser-wasm-worker` for worker email parsing, [documentation](https://temp-mail-docs.awsl.uk/en/guide/feature/mail_parser_wasm_worker.html)
- Add user email length validation configuration `MIN_ADDRESS_LEN` and `MAX_ADDRESS_LEN`
- Fix `pages function` not forwarding `telegram` api issue
## v0.5.0
- UI: Add local cache for address management
- worker: Add `FORWARD_ADDRESS_LIST` global email forwarding address (equivalent to `catch all`)
- UI: Multi-language uses routing for switching
- Add save attachments to S3 feature
- UI: Add received email list `batch delete` and `batch download`
## v0.4.6
- Worker configuration file adds `TITLE = "Custom Title"`, can customize website title
- Fix KV not bound unable to delete address issue
## v0.4.5
- UI lazy load
- telegram bot adds user global push feature (admin users)
- Add support for cloudflare verified user sending emails
- Add using `resend` to send emails, `resend` provides http and smtp api, easier to use, documentation: https://temp-mail-docs.awsl.uk/en/guide/config-send-mail.html
## v0.4.4
- Add telegram mini app
- telegram bot adds `unbind`, `delete` commands
- Fix webhook multiline text issue
## v0.4.3
### Breaking Changes
Configuration file `main = "src/worker.js"` changed to `main = "src/worker.ts"`
### Changes
- `telegram bot` whitelist configuration
- `ENABLE_WEBHOOK` add webhook
- UI: admin page uses two-level tabs
- UI: can directly switch addresses on homepage after login
- UI: outbox also uses split view display (similar to inbox)
- `SMTP IMAP Proxy` add outbox viewing
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
## v0.4.2
- Fix some bugs in smtp imap proxy server
- Fix UI interface text errors, interface adds version number
- Add telegram bot documentation https://temp-mail-docs.awsl.uk/en/guide/feature/telegram.html
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
### v0.4.1
- Username limited to max 30 characters
- Fix `/external/api/send_mail` not returning bug (#222)
- Add `IMAP proxy` service, support `IMAP` viewing emails
- UI interface adds version number display
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
### New Contributors
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
## v0.4.0
### DB Changes/Breaking changes
Added user related tables for storing user information
- `db/2024-05-08-patch.sql`
### config changes
Enable user registration email verification requires `KV`
```toml
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
```
### function changes
- Add user registration feature, can bind email addresses, automatically obtain email JWT credentials after binding
- Add default text display for emails, text and HTML email display mode switch button
- Fix `BUG` randomly generated email names are invalid #211
- `admin` email page supports email content search #210
- Fix bug where emails weren't deleted when deleting addresses #213
- UI adds global tab position configuration, side margin configuration
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
## v0.3.3
- Fix Admin delete email error
- UI: Reply email button, quote original email text #186
- Add send email address blacklist
- Add `CF Turnstile` CAPTCHA configuration
- Add `/external/api/send_mail` send email api, use body verification #194
## v0.3.2
## What's Changed
- UI: Add reply email button
- Add scheduled cleanup feature, configurable in admin page (need to enable scheduled task in config file)
- Fix delete account no response issue
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
## v0.3.1
### DB Changes
Added `settings` table for storing general configuration information
- `db/2024-05-01-patch.sql`
### Changes
- `ENABLE_USER_CREATE_EMAIL` whether to allow users to create emails
- Allow admin to create emails without prefix
- Add `SMTP proxy server`, support SMTP sending emails
- Fix some cases where browsers can't load `wasm` use js to parse emails
- Footer adds `COPYRIGHT`
- UI allows users to switch email display mode `v-html` / `iframe`
- Add `admin` account configuration page, support configuring user registration name blacklist
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
## v0.3.0
### Breaking Changes
The prefix of the `address` table will migrate from code to db, please replace `tmp` in the sql below with your prefix, then execute.
If your data is important, please backup your database first.
**Note: Replace prefix**
```sql
update
address
set
name = 'tmp' || name;
```
### Changes
- Migrate the prefix of the `address` table from code to db
- `admin` account page adds send/receive email counts
- `admin` outbox page defaults to show all
- `admin` send permission page supports search by address
- `admin` email page uses split view UI
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
## v0.2.10
- `ENABLE_USER_DELETE_EMAIL` whether to allow users to delete account and emails
- `ENABLE_AUTO_REPLY` whether to enable auto reply
- fetchAddressError prompt improvement
- Auto refresh shows countdown
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
## v0.2.9
- Add rich text editor
- Admin contact info, won't show if not configured, can configure any string `ADMIN_CONTACT = "xx@xx.xxx"`
- Default send email balance, if not set, will be 0 `DEFAULT_SEND_BALANCE = 1`
## v0.2.8
- Allow users to delete emails
- Admin notifies user by email when modifying send permissions
- Send permission defaults to 1
- Add RATE_LIMITER rate limiting for sending emails and creating new addresses
- Some bug fixes
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
- feat: request_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
## v0.2.7
- Added user interface installation documentation
- Support email DKIM
- Rate limiting configuration for `/api/new_address`
## v0.2.6
- Added admin query outbox page
- Add admin data cleaning page
## 2024-04-12 v0.2.5
- Support send email
DB changes:
- `db/2024-04-12-patch.sql`
## 2024-04-10 v0.2.0
### Breaking Changes
- remove `ENABLE_ATTACHMENT` config
- use rust wasm to parse email in frontend
- deprecated api moved to `/api/v1`
### Rust Mail Parser
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
- Faster speed, good attachment support, can display attachment images of emails
- Parsing supports more rfc specifications
### DB changes
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
## Upgrade Step
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
pnpm run deploy
cd ../frontend
pnpm run deploy
```
Note: For historical messages, use the Deploy New web page to view old data.
```bash
git checkout feature/backup
cd frontend
# Create a new pages for accessing old data
pnpm run deploy --project-name temp-email-v1
```
## 2024-04-09 v0.0.0
release v0.0.0
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`

103
CLAUDE.md Normal file
View File

@@ -0,0 +1,103 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Structure
- **Backend**: `worker/` — Cloudflare Workers API using Hono framework. Entry: `worker/src/worker.ts`, APIs under `worker/src/*_api/`.
- **Frontend**: `frontend/` — Vue 3 + Naive UI app deployed to Cloudflare Pages. Routes in `frontend/src/router/`.
- **Pages middleware**: `pages/functions/_middleware.js` — Routes API calls to Worker backend.
- **Mail parser**: `mail-parser-wasm/` — Rust WASM email parser.
- **SMTP/IMAP proxy**: `smtp_proxy_server/` — Python proxy server.
- **DB schema/migrations**: `db/` — SQLite via Cloudflare D1, dated migration patches.
- **Docs**: `vitepress-docs/` — VitePress documentation site (zh + en).
- **E2E tests**: `e2e/` — Playwright tests in Docker Compose (API, browser, SMTP proxy).
- **Changelogs**: `CHANGELOG.md` (中文) + `CHANGELOG_EN.md` (English).
## Build & Dev Commands
Run inside each subfolder with `pnpm`:
| Folder | Dev | Build | Lint | Deploy |
|--------|-----|-------|------|--------|
| `worker/` | `pnpm dev` | `pnpm build` | `pnpm lint` | `pnpm deploy` |
| `frontend/` | `pnpm dev` | `pnpm build` | — | `pnpm deploy` |
| `vitepress-docs/` | `pnpm dev` | `pnpm build` | — | — |
| `mail-parser-wasm/` | — | `wasm-pack build --release` | — | — |
SMTP proxy: `pip install -r smtp_proxy_server/requirements.txt` then `python smtp_proxy_server/main.py`.
## E2E Tests
Tests run in Docker Compose with Playwright. From `e2e/`:
```bash
npm test # Build, run all tests, exit
npm run test:down # Clean up containers
```
Test categories: `tests/api/` (API tests), `tests/browser/` (UI tests with Chromium), `tests/smtp-proxy/` (SMTP/IMAP proxy tests).
The Docker frontend serves over **HTTPS** (self-signed cert) with Vite proxy to worker — required for WebAuthn (`navigator.credentials`) and `crypto.subtle` which need a secure context. Browser tests use `ignoreHTTPSErrors: true`.
Key patterns for browser tests:
- Frontend hashes passwords with SHA-256 (`crypto.subtle`) before sending — API test registration must use pre-hashed passwords if UI login is needed.
- VueUse `useStorage('key', '')` with string default uses **raw string** serialization — set localStorage with raw value, not `JSON.stringify()`.
- WebAuthn browser tests use CDP virtual authenticator (`WebAuthn.enable` + `WebAuthn.addVirtualAuthenticator`).
## Architecture
### Worker Auth Flow (`worker/src/worker.ts`)
Three auth layers applied via Hono middleware, each using different headers:
| Path prefix | Header | Purpose |
|-------------|--------|---------|
| `/api/*` | `Authorization: Bearer <jwt>` | Address (mailbox) credential |
| `/user_api/*` | `x-user-token` | User account JWT |
| `/admin/*` | `x-admin-auth` | Admin password |
| (any) | `x-user-access-token` | User role-based access token |
| (any) | `x-custom-auth` | Optional global access password |
| (any) | `x-lang` | Language preference (`en`/`zh`) |
Public endpoints (no auth): `/open_api/*`, `/user_api/login`, `/user_api/register`, `/user_api/passkey/authenticate_*`, `/user_api/oauth2/*`.
### Worker Email Flow (`worker/src/email/`)
Cloudflare Email Worker entry: `email()` in `worker/src/email/index.ts`. Processing pipeline:
1. Parse raw email → check junk → check address exists
2. Auto-reply if configured → forward if configured → webhook if enabled
3. Store in D1 database
### Frontend State (`frontend/src/store/index.js`, `frontend/src/api/index.js`)
Global state via VueUse `useStorage` for persistence. The `api` module wraps axios with auto-attached auth headers and fingerprinting. API base URL comes from `VITE_API_BASE` env var (empty = same origin).
## Coding Style
- `worker/` uses TypeScript + ESLint; `frontend/` uses Vue SFCs.
- Keep existing naming patterns: `*_api/` folders, `utils/`, `models/`.
- ESM imports only (`type: module`).
## Commits & PRs
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `style:`, `refactor:`, `perf:`.
- PRs should explain scope; add screenshots for UI changes.
- Use squash merge for PRs.
## Post-Task Checklist (IMPORTANT)
After completing any feature, bug fix, or improvement, **always check**:
1. **CHANGELOG.md** (中文) and **CHANGELOG_EN.md** (English) — both must be updated under the current `(main)` version section with the change entry. Follow the existing format: `- feat/fix/docs: |模块| 描述`.
2. **Documentation** — if the change involves new environment variables, new API endpoints, or configuration changes, update the corresponding docs in `vitepress-docs/docs/zh/` and `vitepress-docs/docs/en/`. Key files:
- `guide/worker-vars.md` — Worker environment variables
- `guide/ui/` — Frontend deployment docs
- `guide/feature/` — Feature-specific docs
- `api/` — API reference docs
3. **Both languages** — docs and changelogs exist in Chinese and English; always update both.
## Config
- Worker settings in `worker/wrangler.toml` (see `wrangler.toml.template` for bindings).
- Frontend uses `VITE_*` env vars. Don't commit secrets.

101
README.md
View File

@@ -1,5 +1,5 @@
<!-- markdownlint-disable-file MD033 MD045 -->
# 🚀 Cloudflare 临时邮箱 - 免费搭建临时邮件服务
# Cloudflare 临时邮箱 - 免费搭建临时邮件服务
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
@@ -29,36 +29,39 @@
</p>
<p align="center">
<a href="README.md">🇨🇳 中文文档</a> |
<a href="README_EN.md">🇺🇸 English Document</a>
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
</p>
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
**🎉 一个功能完整的临时邮箱服务!**
**一个功能完整的临时邮箱服务!**
- 🆓 **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
- **高性能** - Rust WASM 邮件解析,响应速度极快
- 🎨 **现代化界面** - 响应式设计,支持多语言,操作简便
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
- **高性能** - Rust WASM 邮件解析,响应速度极快
- **现代化界面** - 响应式设计,支持多语言,操作简便
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性
- **Agent 友好** - 内置邮箱 [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md),方便 AI agent 使用邮箱
- **移动端管理** - 社区客户端 [CloudMail](https://github.com/Lur1N77777/CloudMail),支持 Android 管理后台和邮箱管理
## 📚 部署文档 - 快速开始
## 部署文档 - 快速开始
[📖 部署文档](https://temp-mail-docs.awsl.uk) | [🚀 Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
[部署文档](https://temp-mail-docs.awsl.uk) | [Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
<a href="https://temp-mail-docs.awsl.uk/zh/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>
<summary>服务状态监控(点击收缩/展开)</summary>
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -68,7 +71,7 @@
</details>
<details>
<summary>Star History点击收缩/展开)</summary>
<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" />
@@ -79,42 +82,44 @@
</details>
<details open>
<summary>📖 目录(点击收缩/展开)</summary>
<summary>目录(点击收缩/展开)</summary>
- [🚀 Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#-cloudflare-临时邮箱---免费搭建临时邮件服务)
- [📚 部署文档 - 快速开始](#-部署文档---快速开始)
- [📝 更新日志](#-更新日志)
- [🎯 在线体验](#-在线体验)
- [核心功能](#-核心功能)
- [📧 邮件处理](#-邮件处理)
- [👥 用户管理](#-用户管理)
- [🔧 管理功能](#-管理功能)
- [🌐 多语言与界面](#-多语言与界面)
- [🤖 集成与扩展](#-集成与扩展)
- [🏗️ 技术架构](#-技术架构)
- [🏛️ 系统架构](#-系统架构)
- [🛠️ 技术栈](#-技术栈)
- [📦 主要组件](#-主要组件)
- [🌟 加入社区](#-加入社区)
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
- [部署文档 - 快速开始](#部署文档---快速开始)
- [更新日志](#更新日志)
- [在线体验](#在线体验)
- [核心功能](#核心功能)
- [邮件处理](#邮件处理)
- [用户管理](#用户管理)
- [管理功能](#管理功能)
- [多语言与界面](#多语言与界面)
- [集成与扩展](#集成与扩展)
- [技术架构](#技术架构)
- [系统架构](#系统架构)
- [技术栈](#技术栈)
- [主要组件](#主要组件)
- [加入社区](#加入社区)
</details>
## 核心功能
## 核心功能
<details open>
<summary>核心功能详情(点击收缩/展开)</summary>
<summary>核心功能详情(点击收缩/展开)</summary>
### 📧 邮件处理
### 邮件处理
- [x] 使用 `rust wasm` 解析邮件解析速度快几乎所有邮件都能解析node 的解析模块解析邮件失败的邮件rust wasm 也能解析成功
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- [x] 支持为指定基础域名创建随机二级域名邮箱地址,更适合收件隔离场景
- [x] 支持发送邮件,支持 `DKIM` 验证
- [x] 支持 `SMTP``Resend` 等多种发送方式
- [x] 支持 `SMTP``Resend` 等多种发送方式
- [x] 增加查看 `附件` 功能,支持附件图片显示
- [x] 支持 S3 附件存储和删除功能
- [x] 垃圾邮件检测和黑白名单配置
- [x] 邮件转发功能,支持全局转发地址
### 👥 用户管理
### 用户管理
- [x] 使用 `凭证` 重新登录之前的邮箱
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
@@ -123,7 +128,7 @@
- [x] 用户角色管理,支持多角色域名和前缀配置
- [x] 用户收件箱查看,支持地址和关键词过滤
### 🔧 管理功能
### 管理功能
- [x] 完整的 admin 控制台
- [x] `admin` 后台创建无前缀邮箱
@@ -132,7 +137,7 @@
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
- [x] 增加访问密码,可作为私人站点
### 🌐 多语言与界面
### 多语言与界面
- [x] 前后台均支持多语言
- [x] 现代化 UI 设计,支持响应式布局
@@ -140,29 +145,31 @@
- [x] 使用 shadow DOM 防止样式污染
- [x] 支持 URL JWT 参数自动登录
### 🤖 集成与扩展
### 集成与扩展
- [x] 完整的 `Telegram Bot` 支持,以及 `Telegram` 推送Telegram Bot 小程序
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件,`IMAP` 查看邮件
- [x] Webhook 支持,消息推送集成
- [x] 支持 `CF Turnstile` 人机验证
- [x] 限流配置,防止滥用
- [x] **Agent 友好**:内置 [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skillAI agent 可直接消费邮箱,详见 [文档](vitepress-docs/docs/zh/guide/feature/agent-email.md)
- [x] 社区移动端管理客户端:[CloudMail](https://github.com/Lur1N77777/CloudMail) 基于 Expo / React Native面向本项目兼容 API提供 Android 管理员后台、地址管理、收件/发件/未知邮件、验证码快捷复制、OLED 黑主题和本地分组。
</details>
## 🏗️ 技术架构
## 技术架构
<details>
<summary>🏗️ 技术架构详情(点击收缩/展开)</summary>
<summary>技术架构详情(点击收缩/展开)</summary>
### 🏛️ 系统架构
### 系统架构
- **数据库**: Cloudflare D1 作为主数据库
- **前端部署**: 使用 Cloudflare Pages 部署前端
- **后端部署**: 使用 Cloudflare Workers 部署后端
- **邮件转发**: 使用 Cloudflare Email Routing
### 🛠️ 技术栈
### 技术栈
- **前端**: Vue 3 + Vite + TypeScript
- **后端**: TypeScript + Cloudflare Workers
@@ -171,7 +178,7 @@
- **存储**: Cloudflare KV + R2 (可选 S3)
- **代理服务**: Python SMTP/IMAP Proxy Server
### 📦 主要组件
### 主要组件
- **Worker**: 核心后端服务
- **Frontend**: Vue 3 用户界面
@@ -182,6 +189,14 @@
</details>
## 🌟 加入社区
### 提醒
- 在Resend添加域名记录时如果您域名解析服务商正在托管您的3级域名a.b.com请删除Resend生成的默认name中二级域名前缀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,46 +1,201 @@
<!-- markdownlint-disable-file MD033 MD045 -->
# Cloudflare Temp Email
# Cloudflare Temp Email - Free Temporary Email Service
<p align="center">
<a href="README.md">🇨🇳 中文</a> |
<a href="README_EN.md">🇺🇸 English</a>
</p>
**A fully-featured temporary email service built on Cloudflare's free services.**
> This project is for learning and personal use only.
## 🚀 Quick Start
- [📖 Documentation](https://temp-mail-docs.awsl.uk/en/)
- [🎯 Live Demo](https://mail.awsl.uk/)
- [📝 CHANGELOG](CHANGELOG.md)
<p align="center">
<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">
<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>
## ✨ Key Features
<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>
- **<2A> Email Processing**: Rust WASM parser, SMTP/IMAP support, attachments, auto-reply
- **👥 User Management**: OAuth2 login, Passkey authentication, role management
- **🌐 Admin Panel**: Complete admin console, user management, scheduled cleanup
- **🤖 Integrations**: Telegram Bot, webhooks, CAPTCHA, rate limiting
- **<2A> Modern UI**: Multi-language, responsive design, JWT auto-login
<p align="center">
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
</p>
## 🏗️ Tech Stack
> 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.
- **Frontend**: Vue 3 + TypeScript + Vite
- **Backend**: Cloudflare Workers + D1 Database
- **Email**: Cloudflare Email Routing + Rust WASM Parser
**A fully-featured temporary email service!**
- **Completely Free** - Built on Cloudflare's free services with zero cost
- **High Performance** - Rust WASM email parsing for extremely fast response
- **Modern UI** - Responsive design with multi-language support and easy operation
- **Address Password** - Support setting individual passwords for email addresses to enhance security
- **Agent-friendly** - Built-in mailbox [`skill`](skills/cf-temp-mail-agent-mail/SKILL.md) for AI agents
- **Mobile admin** - Community client [CloudMail](https://github.com/Lur1N77777/CloudMail) for Android admin and mailbox management
## Deployment Documentation - Quick Start
[Documentation](https://temp-mail-docs.awsl.uk) | [Github Action Deployment Guide](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
See [CHANGELOG](CHANGELOG.md) for the latest updates.
## Live Demo
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<details>
<summary>Service Status Monitoring (Click to expand/collapse)</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 (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" />
</picture>
</details>
<details open>
<summary>Table of Contents (Click to expand/collapse)</summary>
- [Cloudflare Temp Email - Free Temporary Email Service](#cloudflare-temp-email---free-temporary-email-service)
- [Deployment Documentation - Quick Start](#deployment-documentation---quick-start)
- [Changelog](#changelog)
- [Live Demo](#live-demo)
- [Core Features](#core-features)
- [Email Processing](#email-processing)
- [User Management](#user-management)
- [Admin Features](#admin-features)
- [Multi-language \& Interface](#multi-language--interface)
- [Integration \& Extensions](#integration--extensions)
- [Technical Architecture](#technical-architecture)
- [System Architecture](#system-architecture)
- [Tech Stack](#tech-stack)
- [Main Components](#main-components)
- [Join the Community](#join-the-community)
</details>
## Core Features
<details open>
<summary>Core Features Details (Click to expand/collapse)</summary>
### Email Processing
- [x] Use `rust wasm` to parse emails, with fast parsing speed. Almost all emails can be parsed. Even emails that Node.js parsing modules fail to parse can be successfully parsed by rust wasm
- [x] **AI Email Recognition** - Use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
- [x] Support optional random second-level subdomain mailbox creation for selected base domains
- [x] Support sending emails with `DKIM` verification
- [x] Support multiple sending methods such as `SMTP` and `Resend`
- [x] Add attachment viewing feature with support for displaying attachment images
- [x] Support S3 attachment storage and deletion
- [x] Spam detection and blacklist/whitelist configuration
- [x] Email forwarding feature with global forwarding address support
### User Management
- [x] Use `credentials` to log in to previously used mailboxes
- [x] Add complete user registration and login functionality. Users can bind email addresses and automatically obtain email JWT credentials to switch between different mailboxes after binding
- [x] Support `OAuth2` third-party login (Github, Authentik, etc.)
- [x] Support `Passkey` passwordless login
- [x] User role management with support for multi-role domain and prefix configuration
- [x] User inbox viewing with address and keyword filtering support
### Admin Features
- [x] Complete admin console
- [x] Create mailboxes without prefix in `admin` backend
- [x] Admin user management page with user address viewing feature
- [x] Scheduled cleanup function with support for multiple cleanup strategies
- [x] Get mailboxes with custom names, `admin` can configure blacklist
- [x] Add access password for use as a private site
### Multi-language & Interface
- [x] Both frontend and backend support multi-language
- [x] Modern UI design with responsive layout
- [x] Google Ads integration support
- [x] Use shadow DOM to prevent style pollution
- [x] Support URL JWT parameter auto-login
### Integration & Extensions
- [x] Complete `Telegram Bot` support, `Telegram` push notifications, and Telegram Bot mini app
- [x] Add `SMTP proxy server` supporting `SMTP` for sending emails and `IMAP` for viewing emails
- [x] Webhook support and message push integration
- [x] Support `CF Turnstile` CAPTCHA verification
- [x] Rate limiting configuration to prevent abuse
- [x] **Agent-friendly**: bundled [`cf-temp-mail-agent-mail`](skills/cf-temp-mail-agent-mail/SKILL.md) skill lets AI agents consume a mailbox directly, see [docs](vitepress-docs/docs/en/guide/feature/agent-email.md)
- [x] Community mobile admin client: [CloudMail](https://github.com/Lur1N77777/CloudMail) is built with Expo / React Native for this project's compatible API, providing an Android admin console, address management, inbox/sent/unknown mail, quick verification-code copy, OLED black theme, and local grouping.
</details>
## Technical Architecture
<details>
<summary>Technical Architecture Details (Click to expand/collapse)</summary>
### System Architecture
- **Database**: Cloudflare D1 as the main database
- **Frontend Deployment**: Deploy frontend using Cloudflare Pages
- **Backend Deployment**: Deploy backend using Cloudflare Workers
- **Email Routing**: Use Cloudflare Email Routing
### Tech Stack
- **Frontend**: Vue 3 + Vite + TypeScript
- **Backend**: TypeScript + Cloudflare Workers
- **Email Parsing**: Rust WASM (mail-parser-wasm)
- **Database**: Cloudflare D1 (SQLite)
- **Storage**: Cloudflare KV + R2 (optional S3)
- **Proxy Service**: Python SMTP/IMAP Proxy Server
## 🌟 Community
### Main Components
- **Worker**: Core backend service
- **Frontend**: Vue 3 user interface
- **Mail Parser WASM**: Rust email parsing module
- **SMTP Proxy Server**: Python email proxy service
- **Pages Functions**: Cloudflare Pages middleware
- **Documentation**: VitePress documentation site
</details>
### Important Notes
- When adding domain records in Resend, if your DNS provider is hosting your 3rd level domain a.b.com, please remove the 2nd level domain prefix b from the default name generated by Resend, otherwise it will add a.b.b.com, causing verification to fail. After adding the record, you can verify it using:
```bash
nslookup -qt="mx" a.b.com 1.1.1.1
```
## Join the Community
- [Telegram](https://t.me/cloudflare_temp_email)
## 📄 License
MIT License - see [LICENSE](LICENSE) for details.

4
db/2025-09-23-patch.sql Normal file
View File

@@ -0,0 +1,4 @@
ALTER TABLE
address
ADD
password TEXT;

View File

@@ -0,0 +1,4 @@
-- Add metadata column to raw_mails table for storing AI extraction results and other metadata
-- This column stores JSON data with flexible schema for various analysis results
ALTER TABLE raw_mails ADD COLUMN metadata TEXT;

View File

@@ -0,0 +1,4 @@
-- Add index on message_id column in raw_mails table
-- This index improves performance for queries filtering/updating by message_id
-- Example: UPDATE raw_mails SET metadata = ? WHERE message_id = ?
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);

View File

@@ -0,0 +1,8 @@
-- Add source_meta column to address table for tracking address creation source
-- For web: stores IP address (e.g., "192.168.1.1") or "web:unknown" as fallback
-- For telegram: stores "tg:{userId}" (e.g., "tg:123456789")
-- For admin: stores "admin"
ALTER TABLE address ADD COLUMN source_meta TEXT;
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);

View File

@@ -0,0 +1,2 @@
-- Add raw_blob BLOB column for gzip-compressed email storage
ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;

View File

@@ -4,20 +4,34 @@ CREATE TABLE IF NOT EXISTS raw_mails (
source TEXT,
address TEXT,
raw TEXT,
raw_blob BLOB,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
password TEXT,
source_meta TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -50,6 +64,8 @@ CREATE TABLE IF NOT EXISTS sendbox (
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,

13
e2e/Dockerfile.e2e Normal file
View File

@@ -0,0 +1,13 @@
# Keep this version in sync with @playwright/test in package.json
FROM mcr.microsoft.com/playwright:v1.58.2-noble
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
WORKDIR /app/e2e
COPY e2e/package.json e2e/package-lock.json ./
RUN npm ci
COPY e2e/ .
ENTRYPOINT ["/app/e2e/scripts/docker-entrypoint.sh"]

47
e2e/Dockerfile.frontend Normal file
View File

@@ -0,0 +1,47 @@
FROM node:20-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
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
COPY frontend/ .
# Generate self-signed cert for HTTPS (required for WebAuthn/crypto.subtle)
RUN openssl req -x509 -newkey rsa:2048 -keyout /tmp/key.pem -out /tmp/cert.pem \
-days 365 -nodes -subj '/CN=frontend'
# Allow Docker internal hostnames (e.g. "frontend") to pass Vite's host check.
# Configure HTTPS with self-signed cert for secure context (WebAuthn/crypto.subtle).
# Proxy API paths to the worker to avoid mixed-content (HTTPS->HTTP) blocking.
RUN mv vite.config.js vite.config.original.js && \
echo 'import config from "./vite.config.original.js";\
import fs from "fs";\
const workerTarget = process.env.VITE_WORKER_URL || "http://worker:8787";\
config.server = {\
...config.server,\
allowedHosts: true,\
https: {\
key: fs.readFileSync("/tmp/key.pem"),\
cert: fs.readFileSync("/tmp/cert.pem"),\
},\
proxy: {\
"/api": { target: workerTarget, changeOrigin: true },\
"/admin": { target: workerTarget, changeOrigin: true },\
"/user_api": { target: workerTarget, changeOrigin: true },\
"/open_api": { target: workerTarget, changeOrigin: true },\
"/external": { target: workerTarget, changeOrigin: true },\
"/health_check": { target: workerTarget, changeOrigin: true },\
},\
};\
export default config;' > vite.config.js
# Empty VITE_API_BASE so frontend uses same-origin (proxied through Vite)
ENV VITE_API_BASE=
EXPOSE 5173
CMD ["pnpm", "exec", "vite", "--port", "5173", "--host", "0.0.0.0"]

19
e2e/Dockerfile.worker Normal file
View File

@@ -0,0 +1,19 @@
FROM node:20-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
WORKDIR /app/worker
COPY worker/package.json worker/pnpm-lock.yaml ./
COPY worker/patches/ patches/
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
COPY worker/src/ src/
COPY worker/tsconfig.json ./
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
COPY ${WRANGLER_TOML} wrangler.toml
EXPOSE 8787
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]

57
e2e/README.md Normal file
View File

@@ -0,0 +1,57 @@
# E2E Tests
End-to-end tests for Cloudflare Temp Email using [Playwright](https://playwright.dev/) and [Mailpit](https://mailpit.axllent.org/), fully containerized with Docker Compose.
## Prerequisites
- **Docker** and **Docker Compose**
## Quick Start
```bash
cd e2e
# Build, start all services, run tests, and exit
npm test
# Clean up containers and volumes
npm run test:down
```
`npm test` runs `docker compose up --build`, which:
1. Starts **Mailpit** (SMTP on :1025, HTTP API on :8025)
2. Builds and starts the **Worker** (wrangler dev on :8787)
3. Builds and starts the **Frontend** (vite dev on :5173)
4. Builds and runs the **E2E runner** (Playwright), which waits for services, initializes the DB, and runs all tests
The exit code reflects the test result.
## Test Structure
| Project | Directory | What it tests |
|---------|-----------|---------------|
| `api` | `tests/api/` | Worker API endpoints — health check, address CRUD, send mail via SMTP |
| `browser` | `tests/browser/` | Frontend UI — login, inbox view, reply with HTML, XSS sanitization |
## Services
| Service | Container | Port | Purpose |
|---------|-----------|------|---------|
| Mailpit SMTP | `mailpit` | 1025 | Captures outgoing emails |
| Mailpit HTTP | `mailpit` | 8025 | API to verify captured emails |
| Worker | `worker` | 8787 | Backend API with E2E config |
| Frontend | `frontend` | 5173 | Vue frontend dev server |
## Test Results
Test results and HTML reports are exported via volumes:
- `e2e/test-results/` — test artifacts
- `e2e/playwright-report/` — HTML report
## Configuration
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
- `E2E_TEST_MODE = true` — enables test seed endpoint
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
- SMTP pointed at Mailpit container (`mailpit:1025`)

178
e2e/docker-compose.yml Normal file
View File

@@ -0,0 +1,178 @@
services:
mailpit:
image: axllent/mailpit:v1.29
ports:
- "1025:1025"
- "8025:8025"
worker:
build:
context: ..
dockerfile: e2e/Dockerfile.worker
ports:
- "8787:8787"
depends_on:
- mailpit
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8787/health_check"]
interval: 3s
timeout: 5s
start_period: 10s
retries: 20
worker-subdomain:
build:
context: ..
dockerfile: e2e/Dockerfile.worker
ports:
- "8789:8789"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8789", "--ip", "0.0.0.0"]
depends_on:
- mailpit
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8789/health_check"]
interval: 3s
timeout: 5s
start_period: 10s
retries: 20
worker-env-off:
build:
context: ..
dockerfile: e2e/Dockerfile.worker
ports:
- "8790:8790"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
volumes:
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
depends_on:
- mailpit
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8790/health_check"]
interval: 3s
timeout: 5s
start_period: 10s
retries: 20
worker-gzip:
build:
context: ..
dockerfile: e2e/Dockerfile.worker
args:
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.gzip
ports:
- "8788:8788"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8788", "--ip", "0.0.0.0"]
depends_on:
- mailpit
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8788/health_check"]
interval: 3s
timeout: 5s
start_period: 10s
retries: 20
worker-send-mail-domain:
build:
context: ..
dockerfile: e2e/Dockerfile.worker
args:
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.send-mail-domain
ports:
- "8791:8791"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8791", "--ip", "0.0.0.0"]
depends_on:
- mailpit
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8791/health_check"]
interval: 3s
timeout: 5s
start_period: 10s
retries: 20
frontend:
build:
context: ..
dockerfile: e2e/Dockerfile.frontend
ports:
- "5173:5173"
depends_on:
worker:
condition: service_healthy
smtp-proxy:
build:
context: ../smtp_proxy_server
dockerfile: dockerfile
ports:
- "11025:8025"
- "11143:11143"
environment:
PROXY_URL: http://worker:8787
PORT: "8025"
IMAP_PORT: "11143"
depends_on:
worker:
condition: service_healthy
smtp-proxy-tls:
build:
context: ../smtp_proxy_server
dockerfile: dockerfile
ports:
- "11026:8026"
- "11144:11144"
environment:
PROXY_URL: http://worker:8787
PORT: "8026"
IMAP_PORT: "11144"
smtp_tls_cert: /certs/cert.pem
smtp_tls_key: /certs/key.pem
imap_tls_cert: /certs/cert.pem
imap_tls_key: /certs/key.pem
entrypoint: ["/bin/bash", "/e2e-scripts/smtp-tls-entrypoint.sh"]
volumes:
- ./scripts:/e2e-scripts:ro
depends_on:
worker:
condition: service_healthy
e2e-runner:
build:
context: ..
dockerfile: e2e/Dockerfile.e2e
environment:
WORKER_URL: http://worker:8787
WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789
WORKER_URL_ENV_OFF: http://worker-env-off:8790
WORKER_GZIP_URL: http://worker-gzip:8788
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
FRONTEND_URL: https://frontend:5173
MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy
SMTP_PROXY_SMTP_PORT: "8025"
SMTP_PROXY_IMAP_PORT: "11143"
SMTP_PROXY_TLS_HOST: smtp-proxy-tls
SMTP_PROXY_TLS_SMTP_PORT: "8026"
SMTP_PROXY_TLS_IMAP_PORT: "11144"
CI: "true"
depends_on:
worker:
condition: service_healthy
worker-subdomain:
condition: service_healthy
worker-env-off:
condition: service_healthy
worker-gzip:
condition: service_healthy
worker-send-mail-domain:
condition: service_healthy
frontend:
condition: service_started
smtp-proxy:
condition: service_started
smtp-proxy-tls:
condition: service_started
volumes:
- ./test-results:/app/e2e/test-results
- ./playwright-report:/app/e2e/playwright-report

View File

@@ -0,0 +1,279 @@
import type { APIRequestContext } from '@playwright/test';
import { createHash } from 'crypto';
import WebSocket from 'ws';
export const WORKER_URL = process.env.WORKER_URL!;
export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
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
* with persistent D1 data from previous test runs.
* Returns the JWT and full address string.
*/
export async function createTestAddress(
ctx: APIRequestContext,
name: string,
domain: string = TEST_DOMAIN
): Promise<{ jwt: string; address: string; address_id: number }> {
const uniqueName = `${name}${Date.now()}`;
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
data: { name: uniqueName, domain },
});
if (!res.ok()) {
throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
}
const body = await res.json();
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
}
/**
* Seed a test email by exercising the real worker email() handler
* via the admin test endpoint.
*/
export async function seedTestMail(
ctx: APIRequestContext,
address: string,
opts: { subject?: string; html?: string; text?: string; from?: string }
): Promise<void> {
const from = opts.from || `sender@${TEST_DOMAIN}`;
const subject = opts.subject || 'Test Email';
const boundary = `----E2E${Date.now()}`;
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
const textPart = opts.text || 'Hello from E2E';
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`MIME-Version: 1.0`,
`Content-Type: multipart/alternative; boundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
``,
textPart,
`--${boundary}`,
`Content-Type: text/html; charset=utf-8`,
``,
htmlPart,
`--${boundary}--`,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
}
const body = await res.json();
if (!body.success) {
throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
}
}
/**
* Send a mail via admin/send_mail, which saves to sendbox.
*/
export async function sendTestMail(
ctx: APIRequestContext,
fromAddress: string,
opts: { to_mail: string; subject?: string; content?: string; is_html?: boolean }
): Promise<void> {
const res = await ctx.post(`${WORKER_URL}/admin/send_mail`, {
data: {
from_name: '',
from_mail: fromAddress,
to_name: '',
to_mail: opts.to_mail,
subject: opts.subject || 'Test Sent Mail',
content: opts.content || 'Sent mail body from E2E',
is_html: opts.is_html ?? false,
},
});
if (!res.ok()) {
throw new Error(`Failed to send mail: ${res.status()} ${await res.text()}`);
}
}
/**
* Delete all messages in Mailpit.
*/
export async function deleteAllMailpitMessages(ctx: APIRequestContext) {
const res = await ctx.delete(`${MAILPIT_API}/v1/messages`);
if (!res.ok()) {
throw new Error(`Failed to delete Mailpit messages: ${res.status()} ${await res.text()}`);
}
}
/**
* Derive the Mailpit WebSocket URL from the REST API URL.
* MAILPIT_API is like "http://mailpit:8025/api" → ws://mailpit:8025/api/events
*/
function mailpitWsUrl(): string {
return MAILPIT_API.replace(/^http/, 'ws') + '/events';
}
/**
* Wait for a message matching `predicate` to arrive in Mailpit.
*
* Connects to Mailpit's WebSocket `/api/events` and listens for
* `Type: "new"` events. When a matching message arrives, resolves
* immediately — no polling, no arbitrary sleeps.
*
* Returns `{ ready, message }`:
* - `ready` resolves when the WebSocket connection is open
* - `message` resolves with the matched message summary
*
* Usage: await ready before triggering the send to avoid race conditions.
*/
export function onMailpitMessage(
predicate: (msg: any) => boolean,
{ timeout = 10_000 }: { timeout?: number } = {}
): { ready: Promise<void>; message: Promise<any> } {
let readyResolve: () => void;
let readyReject: (err: Error) => void;
const ready = new Promise<void>((resolve, reject) => {
readyResolve = resolve;
readyReject = reject;
});
const message = new Promise<any>((resolve, reject) => {
let settled = false;
const ws = new WebSocket(mailpitWsUrl());
const timer = setTimeout(() => {
ws.close();
if (!settled) { settled = true; reject(new Error('Mailpit message not received within timeout')); }
}, timeout);
ws.on('open', () => readyResolve());
ws.on('message', (data: WebSocket.Data) => {
try {
const event = JSON.parse(data.toString());
if (event.Type === 'new' && predicate(event.Data)) {
clearTimeout(timer);
ws.close();
if (!settled) { settled = true; resolve(event.Data); }
}
} catch { /* ignore parse errors */ }
});
ws.on('close', () => {
clearTimeout(timer);
if (!settled) { settled = true; reject(new Error('Mailpit WebSocket closed before matching message')); }
});
ws.on('error', (err: Error) => {
clearTimeout(timer);
readyReject(err);
if (!settled) { settled = true; reject(err); }
});
});
return { ready, message };
}
/**
* Request send mail access for an address.
* Kept for backward compatibility and manual-request flows. When
* DEFAULT_SEND_BALANCE > 0, send balance may already be auto-initialized
* before this endpoint is called.
*/
export async function requestSendAccess(
ctx: APIRequestContext,
jwt: string
): Promise<void> {
const res = await ctx.post(`${WORKER_URL}/api/request_send_mail_access`, {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!res.ok()) {
throw new Error(`Failed to request send access: ${res.status()} ${await res.text()}`);
}
}
/**
* Fetch the sender access row for an address from the admin API.
*/
export async function getAddressSender(
ctx: APIRequestContext,
address: string,
workerUrl: string = WORKER_URL
): Promise<any> {
const res = await ctx.get(
`${workerUrl}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address)}`,
);
if (!res.ok()) {
throw new Error(`Failed to get address sender: ${res.status()} ${await res.text()}`);
}
const body = await res.json();
if (!Array.isArray(body.results) || body.results.length < 1) {
throw new Error(`address_sender row not found for ${address}`);
}
return body.results[0];
}
/**
* Update a sender access row through the admin API.
*/
export async function updateAddressSender(
ctx: APIRequestContext,
opts: {
address: string;
address_id: number;
balance: number;
enabled: boolean;
},
workerUrl: string = WORKER_URL
): Promise<void> {
const res = await ctx.post(`${workerUrl}/admin/address_sender`, {
data: opts,
});
if (!res.ok()) {
throw new Error(`Failed to update address sender: ${res.status()} ${await res.text()}`);
}
}
/**
* Delete a sender access row through the admin API by its id.
*/
export async function deleteAddressSender(
ctx: APIRequestContext,
id: number,
workerUrl: string = WORKER_URL
): Promise<void> {
const res = await ctx.delete(`${workerUrl}/admin/address_sender/${id}`);
if (!res.ok()) {
throw new Error(`Failed to delete address sender: ${res.status()} ${await res.text()}`);
}
}
/**
* Delete a test address via its JWT.
*/
export async function deleteAddress(
ctx: APIRequestContext,
jwt: string
): Promise<void> {
const res = await ctx.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${jwt}` },
});
if (!res.ok()) {
throw new Error(`Failed to delete address: ${res.status()} ${await res.text()}`);
}
}

View File

@@ -0,0 +1,33 @@
name = "cloudflare_temp_email"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
[[kv_namespaces]]
binding = "KV"
id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
[[d1_databases]]
binding = "DB"
database_name = "e2e-temp-email"
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"

View File

@@ -0,0 +1,34 @@
name = "cloudflare_temp_email_env_off"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = false
JWT_SECRET = "e2e-test-secret-key-env-off"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = false
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
[[kv_namespaces]]
binding = "KV"
id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
[[d1_databases]]
binding = "DB"
database_name = "e2e-temp-email-env-off"
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"

View File

@@ -0,0 +1,34 @@
name = "cloudflare_temp_email_gzip"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
JWT_SECRET = "e2e-test-secret-key"
BLACK_LIST = ""
ENABLE_USER_CREATE_EMAIL = true
ENABLE_USER_DELETE_EMAIL = true
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
ENABLE_MAIL_GZIP = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
[[kv_namespaces]]
binding = "KV"
id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
[[d1_databases]]
binding = "DB"
database_name = "e2e-temp-email-gzip"
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"

View File

@@ -0,0 +1,38 @@
name = "cloudflare_temp_email"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
send_email = [
{ name = "SEND_MAIL" },
]
[vars]
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
ENABLE_USER_DELETE_EMAIL = true
ENABLE_AUTO_REPLY = true
DEFAULT_SEND_BALANCE = 10
ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
[[kv_namespaces]]
binding = "KV"
id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
[[d1_databases]]
binding = "DB"
database_name = "e2e-temp-email"
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"

422
e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,422 @@
{
"name": "cloudflare-temp-email-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"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==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"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==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"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==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.1.tgz",
"integrity": "sha512-DKwpMDR1EWXpV5T7adqQAccN7n684AX3poEZ5F3YoPlm2MyGeKavpRgNr3qptdEQaK+x5SlZ9jigT+cMs4geBA==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"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"
}
},
"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==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"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==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.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",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"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==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"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==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"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==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"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==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"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==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"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==",
"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==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

19
e2e/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "cloudflare-temp-email-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
"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"
},
"dependencies": {
"imapflow": "^1.3.1",
"nodemailer": "^8.0.5"
}
}

45
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,45 @@
import { defineConfig, devices } from '@playwright/test';
const WORKER_BASE = process.env.WORKER_URL!;
const WORKER_GZIP_BASE = process.env.WORKER_GZIP_URL || '';
const FRONTEND_BASE = process.env.FRONTEND_URL!;
export default defineConfig({
timeout: 30_000,
retries: 0,
workers: 1,
reporter: [['html', { open: 'never' }]],
projects: [
{
name: 'api',
testDir: './tests/api',
use: {
baseURL: WORKER_BASE,
},
},
{
name: 'api-gzip',
testDir: './tests/api-gzip',
use: {
baseURL: WORKER_GZIP_BASE,
},
},
{
name: 'smtp-proxy',
testDir: './tests/smtp-proxy',
use: {
baseURL: WORKER_BASE,
},
},
{
name: 'browser',
testDir: './tests/browser',
use: {
baseURL: FRONTEND_BASE,
...devices['Desktop Chrome'],
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
ignoreHTTPSErrors: true,
},
},
],
});

114
e2e/scripts/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -euo pipefail
echo "==> Waiting for worker at $WORKER_URL ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_URL/health_check" > /dev/null 2>&1; then
echo " Worker ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Worker not ready after 60s"
exit 1
fi
sleep 1
done
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
echo "==> Waiting for subdomain worker at $WORKER_URL_SUBDOMAIN ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_URL_SUBDOMAIN/health_check" > /dev/null 2>&1; then
echo " Subdomain worker ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Subdomain worker not ready after 60s"
exit 1
fi
sleep 1
done
fi
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
echo "==> Waiting for env-off worker at $WORKER_URL_ENV_OFF ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_URL_ENV_OFF/health_check" > /dev/null 2>&1; then
echo " Env-off worker ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Env-off worker not ready after 60s"
exit 1
fi
sleep 1
done
fi
if [ -n "${WORKER_GZIP_URL:-}" ]; then
echo "==> Waiting for worker-gzip at $WORKER_GZIP_URL ..."
for i in $(seq 1 60); do
if curl -sf "$WORKER_GZIP_URL/health_check" > /dev/null 2>&1; then
echo " Worker-gzip ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Worker-gzip not ready after 60s"
exit 1
fi
sleep 1
done
fi
echo "==> Waiting for frontend at $FRONTEND_URL ..."
for i in $(seq 1 60); do
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
echo " Frontend ready after ${i}s"
break
fi
if [ "$i" -eq 60 ]; then
echo "ERROR: Frontend not ready after 60s"
exit 1
fi
sleep 1
done
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
for i in $(seq 1 30); do
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
echo " smtp-proxy-tls SMTP ready after ${i}s"
break
fi
if [ "$i" -eq 30 ]; then
echo "WARNING: smtp-proxy-tls SMTP not ready after 30s, continuing anyway"
fi
sleep 1
done
echo "==> Initializing database"
curl -sf -X POST "$WORKER_URL/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_URL/admin/db_migration" > /dev/null
echo " Database initialized"
if [ -n "${WORKER_URL_SUBDOMAIN:-}" ]; then
echo "==> Initializing subdomain worker database"
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_URL_SUBDOMAIN/admin/db_migration" > /dev/null
echo " Subdomain worker database initialized"
fi
if [ -n "${WORKER_URL_ENV_OFF:-}" ]; then
echo "==> Initializing env-off worker database"
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_URL_ENV_OFF/admin/db_migration" > /dev/null
echo " Env-off database initialized"
fi
if [ -n "${WORKER_GZIP_URL:-}" ]; then
echo "==> Initializing gzip worker database"
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_initialize" > /dev/null
curl -sf -X POST "$WORKER_GZIP_URL/admin/db_migration" > /dev/null
echo " Gzip worker database initialized"
fi
echo "==> Running Playwright tests"
exec npx playwright test "$@"

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
CERT_DIR="/certs"
mkdir -p "$CERT_DIR"
if [ ! -f "$CERT_DIR/cert.pem" ] || [ ! -f "$CERT_DIR/key.pem" ]; then
echo "==> Generating self-signed TLS certificate"
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$CERT_DIR/key.pem" -out "$CERT_DIR/cert.pem" \
-days 1 -subj "/CN=smtp-proxy-tls"
echo " Certificate generated"
fi
exec python3 main.py

View File

@@ -0,0 +1,242 @@
import { test, expect } from '@playwright/test';
import { WORKER_GZIP_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
/**
* These tests run against a worker instance with ENABLE_MAIL_GZIP=true.
* They verify gzip-compressed storage and backward-compatible reading.
*/
// Helper: create address on the gzip worker
async function createGzipAddress(ctx: any, name: string) {
const uniqueName = `${name}${Date.now()}`;
const res = await ctx.post(`${WORKER_GZIP_URL}/api/new_address`, {
data: { name: uniqueName, domain: TEST_DOMAIN },
});
if (!res.ok()) throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
const body = await res.json();
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
}
// Helper: seed mail via receiveMail (goes through email() handler → gzip compression)
async function receiveGzipMail(
ctx: any, address: string,
opts: { subject?: string; html?: string; text?: string; from?: string }
) {
const from = opts.from || `sender@${TEST_DOMAIN}`;
const subject = opts.subject || 'Test Email';
const boundary = `----E2E${Date.now()}`;
const htmlPart = opts.html || `<p>${opts.text || 'Hello from E2E'}</p>`;
const textPart = opts.text || 'Hello from E2E';
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`MIME-Version: 1.0`,
`Content-Type: multipart/alternative; boundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
``,
textPart,
`--${boundary}`,
`Content-Type: text/html; charset=utf-8`,
``,
htmlPart,
`--${boundary}--`,
].join('\r\n');
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
const body = await res.json();
if (!body.success) throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`);
}
// Helper: seed mail via seedMail (direct INSERT → plaintext raw, no gzip)
async function seedPlaintextMail(
ctx: any, address: string,
opts: { subject?: string; text?: string; from?: string }
) {
const from = opts.from || `sender@${TEST_DOMAIN}`;
const subject = opts.subject || 'Plaintext Mail';
const messageId = `<e2e-plain-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`Content-Type: text/plain; charset=utf-8`,
``,
opts.text || 'Hello plaintext from E2E',
].join('\r\n');
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
data: { address, source: from, raw, message_id: messageId },
});
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
}
// Helper: delete address on gzip worker
async function deleteGzipAddress(ctx: any, jwt: string) {
await ctx.delete(`${WORKER_GZIP_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${jwt}` },
});
}
test.describe('Mail Gzip Storage', () => {
test.beforeEach(() => {
test.skip(!WORKER_GZIP_URL, 'WORKER_GZIP_URL not set — skipping gzip tests');
});
test('gzip-compressed mail is readable in list', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-list');
try {
await receiveGzipMail(request, address, {
subject: 'Gzip List Test',
text: 'compressed content here',
});
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.ok()).toBe(true);
const { results } = await res.json();
expect(results).toHaveLength(1);
expect(results[0].raw).toContain('Gzip List Test');
expect(results[0].raw).toContain('compressed content here');
} finally {
await deleteGzipAddress(request, jwt);
}
});
test('gzip-compressed mail is readable in detail', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-detail');
try {
await receiveGzipMail(request, address, {
subject: 'Gzip Detail Test',
html: '<b>bold gzip</b>',
});
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}/api/mail/${mailId}`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(detailRes.ok()).toBe(true);
const mail = await detailRes.json();
expect(mail.raw).toContain('Gzip Detail Test');
expect(mail.raw).toContain('<b>bold gzip</b>');
} finally {
await deleteGzipAddress(request, jwt);
}
});
test('mixed: plaintext seed + gzip receive both readable in same list', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-mixed');
try {
// 1. Direct INSERT plaintext (simulates pre-gzip data)
await seedPlaintextMail(request, address, {
subject: 'Old Plaintext Mail',
text: 'legacy plain content',
});
// 2. receiveMail → goes through email() handler → gzip compressed
await receiveGzipMail(request, address, {
subject: 'New Gzip Mail',
text: 'new compressed content',
});
const res = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.ok()).toBe(true);
const { results } = await res.json();
expect(results).toHaveLength(2);
// Both mails should have readable raw content
const subjects = results.map((r: any) => r.raw);
expect(subjects.some((r: string) => r.includes('Old Plaintext Mail'))).toBe(true);
expect(subjects.some((r: string) => r.includes('New Gzip Mail'))).toBe(true);
expect(subjects.some((r: string) => r.includes('legacy plain content'))).toBe(true);
expect(subjects.some((r: string) => r.includes('new compressed content'))).toBe(true);
} finally {
await deleteGzipAddress(request, jwt);
}
});
test('admin internal mail (sendAdminInternalMail) is gzip-compressed and readable', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-admin-mail');
try {
// 1. Request send access → creates address_sender row
const reqAccessRes = await request.post(`${WORKER_GZIP_URL}/api/request_send_mail_access`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(reqAccessRes.ok()).toBe(true);
// 2. Get address_sender id
const senderListRes = await request.get(
`${WORKER_GZIP_URL}/admin/address_sender?limit=10&offset=0&address=${encodeURIComponent(address)}`,
);
expect(senderListRes.ok()).toBe(true);
const senderList = await senderListRes.json();
expect(senderList.results.length).toBeGreaterThanOrEqual(1);
const senderId = senderList.results[0].id;
// 3. Update send access via admin API → triggers sendAdminInternalMail
const updateRes = await request.post(`${WORKER_GZIP_URL}/admin/address_sender`, {
data: { address, address_id: senderId, balance: 99, enabled: true },
});
expect(updateRes.ok()).toBe(true);
// 4. Verify the internal mail is readable
const mailsRes = await request.get(`${WORKER_GZIP_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(mailsRes.ok()).toBe(true);
const { results } = await mailsRes.json();
expect(results.length).toBeGreaterThanOrEqual(1);
// mimetext base64-encodes the Subject header, so match on body content instead
const internalMail = results.find((m: any) => m.raw?.includes('balance: 99'));
expect(internalMail).toBeDefined();
expect(internalMail.raw).toContain('admin@internal');
expect(internalMail.raw).toContain('balance: 99');
expect(internalMail).not.toHaveProperty('raw_blob');
} finally {
await deleteGzipAddress(request, jwt);
}
});
test('raw_blob field is not exposed in API response', async ({ request }) => {
const { jwt, address } = await createGzipAddress(request, 'gzip-noblob');
try {
await receiveGzipMail(request, address, { subject: 'No Blob Leak' });
// Check list response
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);
expect(results[0]).not.toHaveProperty('raw_blob');
// Check detail response
const detailRes = await request.get(`${WORKER_GZIP_URL}/api/mail/${results[0].id}`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const mail = await detailRes.json();
expect(mail).not.toHaveProperty('raw_blob');
} finally {
await deleteGzipAddress(request, jwt);
}
});
});

View File

@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Address Lifecycle', () => {
test('create address, auto-init send balance via settings, then delete', async ({ request }) => {
// Create address
const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test');
expect(address).toContain('@' + TEST_DOMAIN);
expect(jwt).toBeTruthy();
expect(address_id).toBeGreaterThan(0);
// Fetch address settings — balance should auto-initialize from DEFAULT_SEND_BALANCE=10
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.send_balance).toBe(10);
// Delete address
await deleteAddress(request, jwt);
// Verify address is gone — settings should fail
const afterDelete = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(afterDelete.ok()).toBe(false);
});
});

View File

@@ -0,0 +1,167 @@
import { test, expect } from '@playwright/test';
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: passwordHash },
});
expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json();
expect(changePwdBody.success).toBe(true);
// Login with the correct password
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);
// The new JWT should work — verify by fetching settings
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${loginBody.jwt}` },
});
expect(settingsRes.ok()).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
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: passwordHash },
});
expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json();
expect(changePwdBody.success).toBe(true);
// Login with wrong password
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
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,48 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, TEST_DOMAIN, createTestAddress } from '../../fixtures/test-helpers';
// Regression tests for #956: long admin search queries must not trigger
// D1's "LIKE or GLOB pattern too complex" error.
test.describe('Admin Address Query (#956)', () => {
test('short query (subdomain fragment) returns matching address via LIKE', async ({ request }) => {
const created = await createTestAddress(request, 'q956short');
const fragment = created.address.split('@')[0].slice(0, 8);
const res = await request.get(`${WORKER_URL}/admin/address`, {
params: { limit: '20', offset: '0', query: fragment },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(Array.isArray(body.results)).toBe(true);
const names: string[] = body.results.map((r: any) => r.name);
expect(names).toContain(created.address);
});
test('long query (>50-byte pattern) does not crash with D1 LIKE error', async ({ request }) => {
const longQuery = 'a48r893s@5hx7zb.nationalgeographic.algomindtrade.com';
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
const res = await request.get(`${WORKER_URL}/admin/address`, {
params: { limit: '20', offset: '0', query: longQuery },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body.results)).toBe(true);
expect(body.results.length).toBe(0);
expect(body.count).toBe(0);
});
test('long query also works for /admin/users', async ({ request }) => {
const longQuery = 'no-such-user-' + 'x'.repeat(40) + `@${TEST_DOMAIN}`;
expect(new TextEncoder().encode(`%${longQuery}%`).length).toBeGreaterThan(50);
const res = await request.get(`${WORKER_URL}/admin/users`, {
params: { limit: '20', offset: '0', query: longQuery },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(Array.isArray(body.results)).toBe(true);
expect(body.results.length).toBe(0);
expect(body.count).toBe(0);
});
});

View File

@@ -0,0 +1,19 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, TEST_DOMAIN } from '../../fixtures/test-helpers';
test.describe('Admin New Address', () => {
test('should return address_id in response', async ({ request }) => {
const uniqueName = `admin-test${Date.now()}`;
const res = await request.post(`${WORKER_URL}/admin/new_address`, {
data: { name: uniqueName, domain: TEST_DOMAIN },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.address).toContain('@' + TEST_DOMAIN);
expect(body.jwt).toBeTruthy();
expect(body.address_id).toBeGreaterThan(0);
expect(typeof body.address_id).toBe('number');
});
});

View File

@@ -0,0 +1,185 @@
import { test, expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Auto Reply Trigger (#459)', () => {
/**
* Bug #459: source_prefix empty string causes auto-reply to never trigger.
* The old condition `results.source_prefix && ...` short-circuits when
* source_prefix is "" (falsy). Fix: empty source_prefix should match all.
*/
test('empty source_prefix triggers auto-reply for any sender', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-trigger');
try {
// Configure auto-reply with empty source_prefix (match all senders)
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Auto Bot',
subject: 'Auto Reply',
source_prefix: '',
message: 'Thanks for your email!',
enabled: true,
},
},
});
expect(saveRes.ok()).toBe(true);
// Send a mail to the address — should trigger auto-reply
const receiveRes = await seedTestMailWithReply(request, address, {
from: 'anyone@other.com',
subject: 'Hello',
text: 'Test message',
});
expect(receiveRes.success).toBe(true);
expect(receiveRes.replyCalled).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('source_prefix startsWith still works (backward compat)', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-prefix');
try {
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Prefix Bot',
subject: 'Prefix Reply',
source_prefix: 'vip@',
message: 'VIP auto-reply',
enabled: true,
},
},
});
expect(saveRes.ok()).toBe(true);
// Matching sender — should trigger
const matchRes = await seedTestMailWithReply(request, address, {
from: 'vip@example.com',
subject: 'VIP mail',
});
expect(matchRes.replyCalled).toBe(true);
// Non-matching sender — should NOT trigger
const noMatchRes = await seedTestMailWithReply(request, address, {
from: 'random@example.com',
subject: 'Random mail',
});
expect(noMatchRes.replyCalled).toBe(false);
} finally {
await deleteAddress(request, jwt);
}
});
test('source_prefix regex match', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-regex');
try {
// Configure regex: match senders from example.com or example.org
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Regex Bot',
subject: 'Regex Reply',
source_prefix: '/@example\\.(com|org)$/',
message: 'Regex auto-reply',
enabled: true,
},
},
});
expect(saveRes.ok()).toBe(true);
// Matching sender
const matchRes = await seedTestMailWithReply(request, address, {
from: 'user@example.com',
subject: 'Match test',
});
expect(matchRes.replyCalled).toBe(true);
// Another matching sender
const matchRes2 = await seedTestMailWithReply(request, address, {
from: 'user@example.org',
subject: 'Match test 2',
});
expect(matchRes2.replyCalled).toBe(true);
// Non-matching sender
const noMatchRes = await seedTestMailWithReply(request, address, {
from: 'user@other.com',
subject: 'No match test',
});
expect(noMatchRes.replyCalled).toBe(false);
} finally {
await deleteAddress(request, jwt);
}
});
test('disabled auto-reply does not trigger', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'auto-reply-disabled');
try {
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Disabled Bot',
subject: 'Should not reply',
source_prefix: '',
message: 'This should never be sent',
enabled: false,
},
},
});
expect(saveRes.ok()).toBe(true);
const receiveRes = await seedTestMailWithReply(request, address, {
from: 'anyone@other.com',
subject: 'Test disabled',
});
expect(receiveRes.success).toBe(true);
expect(receiveRes.replyCalled).toBe(false);
} finally {
await deleteAddress(request, jwt);
}
});
});
/**
* Send a mail via receive_mail endpoint and return the response
* including replyCalled field.
*/
async function seedTestMailWithReply(
ctx: APIRequestContext,
address: string,
opts: { from?: string; subject?: string; text?: string }
): Promise<{ success: boolean; replyCalled: boolean }> {
const from = opts.from || 'sender@test.example.com';
const subject = opts.subject || 'Test Email';
const text = opts.text || 'Hello from E2E';
const messageId = `<e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
``,
text,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
}
return await res.json();
}

View File

@@ -0,0 +1,73 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Auto Reply Settings', () => {
test('get empty, save, then verify saved settings', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'auto-reply');
try {
// GET auto_reply — should return empty object for new address
const emptyRes = await request.get(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(emptyRes.ok()).toBe(true);
const empty = await emptyRes.json();
expect(Object.keys(empty)).toHaveLength(0);
// POST save auto_reply settings
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Test Bot',
subject: 'Auto Reply',
source_prefix: 'Re:',
message: 'Thanks for your email!',
enabled: true,
},
},
});
expect(saveRes.ok()).toBe(true);
const saveBody = await saveRes.json();
expect(saveBody.success).toBe(true);
// GET auto_reply — should return saved settings
const savedRes = await request.get(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(savedRes.ok()).toBe(true);
const saved = await savedRes.json();
expect(saved.name).toBe('Test Bot');
expect(saved.subject).toBe('Auto Reply');
expect(saved.source_prefix).toBe('Re:');
expect(saved.message).toBe('Thanks for your email!');
expect(saved.enabled).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('save with too long subject returns 400', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'auto-reply-long');
try {
const saveRes = await request.post(`${WORKER_URL}/api/auto_reply`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
auto_reply: {
name: 'Bot',
subject: 'x'.repeat(256),
source_prefix: '',
message: 'Hello',
enabled: true,
},
},
});
expect(saveRes.status()).toBe(400);
const body = await saveRes.text();
expect(body).toContain('too long');
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

@@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
requestSendAccess,
onMailpitMessage,
} from '../../fixtures/test-helpers';
test.describe('Clear Sent Items', () => {
test.beforeEach(async ({ request }) => {
await deleteAllMailpitMessages(request);
});
test('send mail then clear sent items', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'clear-sent');
await requestSendAccess(request, jwt);
try {
const subject = `Clear Sent Test ${Date.now()}`;
// Listen before sending
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
// Send a mail
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: 'Sender',
to_name: 'Recipient',
to_mail: 'recipient@test.example.com',
subject,
content: '<p>test</p>',
is_html: true,
},
});
expect(sendRes.ok()).toBe(true);
await listener.message;
// Verify sendbox has 1 item
const listRes = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(listRes.ok()).toBe(true);
const { results } = await listRes.json();
expect(results.length).toBeGreaterThanOrEqual(1);
// Clear sent items
const clearRes = await request.delete(`${WORKER_URL}/api/clear_sent_items`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(clearRes.ok()).toBe(true);
const clearBody = await clearRes.json();
expect(clearBody.success).toBe(true);
// Verify sendbox is empty
const afterRes = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(afterRes.ok()).toBe(true);
const after = await afterRes.json();
expect(after.results).toHaveLength(0);
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

@@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL } from '../../fixtures/test-helpers';
test.describe('Health & Settings', () => {
test('GET /health_check returns OK', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/health_check`);
expect(res.ok()).toBe(true);
expect(await res.text()).toBe('OK');
});
test('GET /open_api/settings returns correct domains and sendMail enabled', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/open_api/settings`);
expect(res.ok()).toBe(true);
const settings = await res.json();
expect(settings.domains).toContain('test.example.com');
expect(settings.defaultDomains).toContain('test.example.com');
expect(settings.enableSendMail).toBe(true);
expect(settings.enableUserCreateEmail).toBe(true);
expect(settings.enableUserDeleteEmail).toBe(true);
});
});

View File

@@ -0,0 +1,274 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
const ADMIN_PASSWORD = 'e2e-admin-pass';
const RESET_SETTINGS = {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: false,
whitelist: [],
enableDailyLimit: false,
dailyRequestLimit: 1000,
};
test.describe('IP Whitelist Settings', () => {
test.afterEach(async ({ request }) => {
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: RESET_SETTINGS,
});
});
test('get default IP whitelist settings returns disabled with empty list', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
});
expect(res.ok()).toBe(true);
const settings = await res.json();
expect(settings.enableWhitelist).toBeFalsy();
expect(settings.whitelist).toEqual([]);
});
test('save and retrieve IP whitelist settings', async ({ request }) => {
// Save whitelist settings
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: true,
whitelist: ['1.2.3.4', '^192\\.168\\.1\\.\\d+$'],
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
expect(saveRes.ok()).toBe(true);
const saveBody = await saveRes.json();
expect(saveBody.success).toBe(true);
// Retrieve and verify
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
});
expect(getRes.ok()).toBe(true);
const settings = await getRes.json();
expect(settings.enableWhitelist).toBe(true);
expect(settings.whitelist).toEqual(['1.2.3.4', '^192\\.168\\.1\\.\\d+$']);
});
test('whitelist rejects empty list when enabled', async ({ request }) => {
// Note: Frontend blocks this, but backend allows it (empty list = ignored)
// This test verifies backend behavior
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: true,
whitelist: [],
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
// Backend accepts empty whitelist (it will be ignored at runtime)
expect(saveRes.ok()).toBe(true);
});
test('whitelist validates array type', async ({ request }) => {
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: true,
whitelist: 'not-an-array', // Invalid type
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
expect(saveRes.ok()).toBe(false);
expect(saveRes.status()).toBe(400);
});
test('whitelist enforces max size limit', async ({ request }) => {
const largeList = Array.from({ length: 1001 }, (_, i) => `1.2.3.${i % 256}`);
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: true,
whitelist: largeList,
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
expect(saveRes.ok()).toBe(false);
expect(saveRes.status()).toBe(400);
const body = await saveRes.text();
expect(body).toContain('whitelist');
expect(body).toContain('1000');
});
test('backward compatibility: old frontend without whitelist fields', async ({ request }) => {
// Simulate old frontend that doesn't send enableWhitelist/whitelist
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: true,
blacklist: ['10.0.0.1'],
asnBlacklist: [],
fingerprintBlacklist: [],
// enableWhitelist and whitelist omitted
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
// Should succeed with defaults applied
expect(saveRes.ok()).toBe(true);
// Verify defaults were applied
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
});
expect(getRes.ok()).toBe(true);
const settings = await getRes.json();
expect(settings.enableWhitelist).toBe(false);
expect(settings.whitelist).toEqual([]);
});
test('whitelist sanitizes patterns (trims and removes empty)', async ({ request }) => {
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: true,
whitelist: [' 1.2.3.4 ', '', ' ', '5.6.7.8'],
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
expect(saveRes.ok()).toBe(true);
const getRes = await request.get(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
});
expect(getRes.ok()).toBe(true);
const settings = await getRes.json();
// Empty strings should be filtered out, whitespace trimmed
expect(settings.whitelist).toEqual(['1.2.3.4', '5.6.7.8']);
});
test('whitelist rejects invalid regex pattern', async ({ request }) => {
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: { ...RESET_SETTINGS, whitelist: ['^[1.2.3.4$'] }, // invalid regex
});
expect(saveRes.ok()).toBe(false);
expect(saveRes.status()).toBe(400);
expect(await saveRes.text()).toContain('whitelist');
});
test('whitelist rejects non-string elements', async ({ request }) => {
const saveRes = await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: { ...RESET_SETTINGS, whitelist: [1, null] },
});
expect(saveRes.ok()).toBe(false);
expect(saveRes.status()).toBe(400);
});
});
test.describe('IP Whitelist Runtime Behavior', () => {
test('whitelist with empty list allows requests (protection mode)', async ({ request }) => {
// Enable whitelist with empty list
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: true,
whitelist: [],
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
// Try to create address (rate-limited endpoint)
// Should succeed because empty whitelist is ignored
const res = await createTestAddress(request, 'whitelist-empty');
expect(res.jwt).toBeTruthy();
expect(res.address).toBeTruthy();
});
test('whitelist blocks requests when IP does not match whitelist', async ({ request }) => {
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
...RESET_SETTINGS,
enableWhitelist: true,
whitelist: ['1.2.3.4'],
},
});
// In e2e, cf-connecting-ip is absent → fail-closed → 403
const res = await request.post(`${WORKER_URL}/api/new_address`, {
data: { name: `whitelist-block-${Date.now()}`, domain: 'test.example.com' },
});
expect(res.status()).toBe(403);
const body = await res.text();
expect(body).toContain('IP');
});
test('fingerprint blacklist blocks even when cf-connecting-ip is absent', async ({ request }) => {
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
...RESET_SETTINGS,
enabled: true,
fingerprintBlacklist: ['blocked-fingerprint-123'],
},
});
const res = await request.post(`${WORKER_URL}/api/new_address`, {
headers: { 'x-fingerprint': 'blocked-fingerprint-123' },
data: { name: `fp-block-${Date.now()}`, domain: 'test.example.com' },
});
expect(res.status()).toBe(403);
expect(await res.text()).toContain('fingerprint');
});
test.afterEach(async ({ request }) => {
// Reset whitelist to disabled after each test
await request.post(`${WORKER_URL}/admin/ip_blacklist/settings`, {
headers: { 'x-admin-auth': ADMIN_PASSWORD },
data: {
enabled: false,
blacklist: [],
asnBlacklist: [],
fingerprintBlacklist: [],
enableWhitelist: false,
whitelist: [],
enableDailyLimit: false,
dailyRequestLimit: 1000,
},
});
});
});

View File

@@ -0,0 +1,124 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress, hashPassword } from '../../fixtures/test-helpers';
test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled)', () => {
test('settings returns enableGlobalTurnstileCheck as false', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/open_api/settings`);
expect(res.ok()).toBe(true);
const settings = await res.json();
expect(settings.enableGlobalTurnstileCheck).toBe(false);
});
test.describe('/open_api/site_login', () => {
test('returns 401 when no PASSWORDS configured', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/site_login`, {
data: {
password: hashPassword('any-pass'),
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
});
test.describe('/open_api/admin_login', () => {
test('correct hashed password succeeds', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
data: {
password: hashPassword('e2e-admin-pass'),
cf_token: ''
}
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
});
test('wrong password returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
data: {
password: hashPassword('wrong-admin'),
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
test('empty password returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/admin_login`, {
data: {
password: '',
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
});
test.describe('/open_api/credential_login', () => {
test('valid JWT credential succeeds', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'cred-login');
try {
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: {
credential: jwt,
cf_token: ''
}
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('invalid JWT returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: {
credential: 'invalid.jwt.token',
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
test('empty credential returns 401', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: {
credential: '',
cf_token: ''
}
});
expect(res.status()).toBe(401);
});
});
test.describe('/api/address_login with cf_token', () => {
test('address login with empty cf_token works when turnstile disabled', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'addr-cf');
try {
// Set a password
await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
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: hashPassword('addr-pass-123'),
cf_token: ''
},
});
expect(loginRes.ok()).toBe(true);
const body = await loginRes.json();
expect(body.jwt).toBeTruthy();
} finally {
await deleteAddress(request, jwt);
}
});
});
});

View File

@@ -0,0 +1,181 @@
import { createHash } from 'node:crypto';
import { test, expect } from '@playwright/test';
import { WORKER_URL, WORKER_URL_ENV_OFF, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Mail Deletion', () => {
test('user mail deletion is disabled when ENABLE_USER_DELETE_EMAIL is false', async ({ request }) => {
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
const testUserEmail = `mail-delete-e2e-${Date.now()}@test.example.com`;
const testUserPassword = 'test-password-123';
const testUserPasswordHash = createHash('sha256').update(testUserPassword).digest('hex');
const enableRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: false,
},
});
expect(enableRes.ok()).toBe(true);
const registerRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/register`, {
data: { email: testUserEmail, password: testUserPasswordHash },
});
expect(registerRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/login`, {
data: { email: testUserEmail, password: testUserPasswordHash },
});
expect(loginRes.ok()).toBe(true);
const { jwt: userJwt } = await loginRes.json();
expect(userJwt).toBeTruthy();
const createRes = await request.post(`${WORKER_URL_ENV_OFF}/api/new_address`, {
data: {
name: `user-del-disabled${Date.now()}`,
domain: 'test.example.com',
},
});
expect(createRes.ok()).toBe(true);
const { jwt, address, address_id } = await createRes.json();
try {
const bindRes = await request.post(`${WORKER_URL_ENV_OFF}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${jwt}`,
'x-user-token': userJwt,
},
});
expect(bindRes.ok()).toBe(true);
const from = 'sender@test.example.com';
const subject = 'Disabled Mail Delete';
const boundary = `----E2E${Date.now()}`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: <e2e-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test>`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'',
'Hello from E2E',
`--${boundary}`,
'Content-Type: text/html; charset=utf-8',
'',
'<p>Hello from E2E</p>',
`--${boundary}--`,
].join('\r\n');
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
expect(seedRes.ok()).toBe(true);
const seedBody = await seedRes.json();
expect(seedBody.success).toBe(true);
const listRes = await request.get(`${WORKER_URL_ENV_OFF}/user_api/mails?limit=10&offset=0`, {
headers: { 'x-user-token': userJwt },
});
expect(listRes.ok()).toBe(true);
const { results } = await listRes.json();
expect(results).toHaveLength(1);
const targetId = results[0].id;
const delRes = await request.delete(`${WORKER_URL_ENV_OFF}/user_api/mails/${targetId}`, {
headers: { 'x-user-token': userJwt },
});
expect(delRes.status()).toBe(403);
const afterRes = await request.get(`${WORKER_URL_ENV_OFF}/user_api/mails?limit=10&offset=0`, {
headers: { 'x-user-token': userJwt },
});
expect(afterRes.ok()).toBe(true);
const after = await afterRes.json();
expect(after.results).toHaveLength(1);
expect(after.results[0].id).toBe(targetId);
} finally {
const deleteRes = await request.delete(`${WORKER_URL_ENV_OFF}/admin/delete_address/${address_id}`);
expect(deleteRes.ok()).toBe(true);
}
});
test('delete a single mail by ID', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'del-single');
try {
// Seed 3 emails
for (let i = 1; i <= 3; i++) {
await seedTestMail(request, address, { subject: `Mail ${i}` });
}
// List mails — should have 3
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(3);
// Delete the second mail
const targetId = results[1].id;
const delRes = await request.delete(`${WORKER_URL}/api/mails/${targetId}`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(delRes.ok()).toBe(true);
const delBody = await delRes.json();
expect(delBody.success).toBe(true);
// List again — should have 2, and the deleted ID should be gone
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(afterRes.ok()).toBe(true);
const after = await afterRes.json();
expect(after.results).toHaveLength(2);
expect(after.results.every((m: any) => m.id !== targetId)).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('clear entire inbox', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'del-clear');
try {
// Seed 3 emails
for (let i = 1; i <= 3; i++) {
await seedTestMail(request, address, { subject: `Mail ${i}` });
}
// Verify 3 mails exist
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(3);
// Clear inbox
const clearRes = await request.delete(`${WORKER_URL}/api/clear_inbox`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(clearRes.ok()).toBe(true);
const clearBody = await clearRes.json();
expect(clearBody.success).toBe(true);
// Verify inbox is empty
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(afterRes.ok()).toBe(true);
const after = await afterRes.json();
expect(after.results).toHaveLength(0);
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Mail Detail', () => {
test('fetch a single mail by ID', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'detail-get');
try {
// Seed a mail with known content
await seedTestMail(request, address, {
subject: 'Detail Test',
from: 'alice@test.example.com',
html: '<p>Hello detail</p>',
text: 'Hello detail',
});
// List mails to get the ID
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;
// Fetch single mail by ID
const detailRes = await request.get(`${WORKER_URL}/api/mail/${mailId}`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(detailRes.ok()).toBe(true);
const mail = await detailRes.json();
expect(mail.id).toBe(mailId);
expect(mail.address).toBe(address);
expect(mail.source).toBe('alice@test.example.com');
expect(mail.raw).toContain('Detail Test');
} finally {
await deleteAddress(request, jwt);
}
});
test('fetch non-existent mail returns null', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'detail-404');
try {
const res = await request.get(`${WORKER_URL}/api/mail/99999999`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body).toBeNull();
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

@@ -0,0 +1,162 @@
import { test, expect } from '@playwright/test';
import type { APIRequestContext } from '@playwright/test';
import { WORKER_URL } from '../../fixtures/test-helpers';
const TEST_USER_EMAIL = `passkey-e2e-${Date.now()}@test.example.com`;
const TEST_USER_PASSWORD = 'test-password-123';
/**
* Enable user registration via admin API, register a user, and login to get JWT.
*/
async function createTestUser(request: APIRequestContext): Promise<string> {
// Enable user registration (KV setting)
const enableRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
enable: true,
enableMailVerify: false,
},
});
expect(enableRes.ok()).toBe(true);
// Register user
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
});
expect(registerRes.ok()).toBe(true);
// Login to get JWT
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email: TEST_USER_EMAIL, password: TEST_USER_PASSWORD },
});
expect(loginRes.ok()).toBe(true);
const { jwt } = await loginRes.json();
expect(jwt).toBeTruthy();
return jwt;
}
test.describe('Passkey API', () => {
let userJwt: string;
test.beforeAll(async ({ request }) => {
userJwt = await createTestUser(request);
});
test('register_request returns valid WebAuthn options', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_request`, {
headers: { 'x-user-token': userJwt },
data: { domain: 'localhost' },
});
expect(res.ok()).toBe(true);
const options = await res.json();
// Verify WebAuthn registration options structure
expect(options.rp).toBeDefined();
expect(options.rp.id).toBe('localhost');
expect(options.user).toBeDefined();
expect(options.user.name).toBe(TEST_USER_EMAIL);
expect(options.challenge).toBeTruthy();
expect(options.pubKeyCredParams).toBeInstanceOf(Array);
expect(options.pubKeyCredParams.length).toBeGreaterThan(0);
});
test('authenticate_request returns valid WebAuthn options', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_request`, {
data: { domain: 'localhost' },
});
expect(res.ok()).toBe(true);
const options = await res.json();
// Verify WebAuthn authentication options structure
expect(options.challenge).toBeTruthy();
expect(options.rpId).toBe('localhost');
expect(options.allowCredentials).toBeInstanceOf(Array);
});
test('authenticate_response with invalid credential returns error', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/authenticate_response`, {
data: {
domain: 'localhost',
origin: 'http://localhost',
credential: { id: 'nonexistent-passkey-id' },
},
});
expect(res.ok()).toBe(false);
expect(res.status()).toBe(404);
});
test('passkey list is empty for new user', async ({ request }) => {
const res = await request.get(`${WORKER_URL}/user_api/passkey`, {
headers: { 'x-user-token': userJwt },
});
expect(res.ok()).toBe(true);
const passkeys = await res.json();
expect(passkeys).toBeInstanceOf(Array);
expect(passkeys.length).toBe(0);
});
test('passkey list remains empty without registration', async ({ request }) => {
const listRes = await request.get(`${WORKER_URL}/user_api/passkey`, {
headers: { 'x-user-token': userJwt },
});
expect(listRes.ok()).toBe(true);
const passkeys = await listRes.json();
expect(passkeys).toBeInstanceOf(Array);
expect(passkeys.length).toBe(0);
});
test('register_response with invalid credential returns 400', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/register_response`, {
headers: { 'x-user-token': userJwt },
data: {
credential: {
id: 'fake-id',
rawId: 'fake-raw-id',
type: 'public-key',
response: {
attestationObject: 'invalid-data',
clientDataJSON: 'invalid-data',
},
},
origin: 'http://localhost',
passkey_name: 'test-passkey',
},
});
expect(res.ok()).toBe(false);
// Should fail verification
expect(res.status()).toBeGreaterThanOrEqual(400);
});
test('rename nonexistent passkey succeeds silently', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
headers: { 'x-user-token': userJwt },
data: {
passkey_id: 'nonexistent-id',
passkey_name: 'new-name',
},
});
// The SQL UPDATE just affects 0 rows, still returns success
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
});
test('rename with invalid name returns 400', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/user_api/passkey/rename`, {
headers: { 'x-user-token': userJwt },
data: {
passkey_id: 'any-id',
passkey_name: 'x'.repeat(256),
},
});
expect(res.status()).toBe(400);
});
test('delete nonexistent passkey succeeds silently', async ({ request }) => {
const res = await request.delete(`${WORKER_URL}/user_api/passkey/nonexistent-id`, {
headers: { 'x-user-token': userJwt },
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.success).toBe(true);
});
});

View File

@@ -0,0 +1,145 @@
import { test, expect } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
requestSendAccess,
deleteAddress,
deleteAddressSender,
getAddressSender,
updateAddressSender,
} from '../../fixtures/test-helpers';
test.describe('Send Access', () => {
test('request send access stays idempotent when default balance is auto-initialized', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'send-access');
try {
// First request — should succeed even if balance will also auto-init elsewhere.
await requestSendAccess(request, jwt);
// Verify balance is set via settings
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.send_balance).toBe(10);
// Duplicate request should stay safe and idempotent.
const dupRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(dupRes.ok()).toBe(true);
const sender = await getAddressSender(request, address);
expect(sender.balance).toBe(10);
expect(sender.enabled).toBe(1);
} finally {
await deleteAddress(request, jwt);
}
});
test('admin-disabled rows are not overwritten by settings or send', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'sa-admin-blocked');
try {
await requestSendAccess(request, jwt);
const sender = await getAddressSender(request, address);
await updateAddressSender(request, {
address,
address_id: sender.id,
balance: 0,
enabled: false,
});
// Reading settings must not auto-repair an admin-disabled row.
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.send_balance).toBe(0);
const stillDisabled = await getAddressSender(request, address);
expect(stillDisabled.balance).toBe(0);
expect(stillDisabled.enabled).toBe(0);
// Attempting to send must also fail and must not auto-repair the row.
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: 'E2E',
to_name: 'E2E',
to_mail: 'recipient@test.example.com',
subject: 'should not send',
content: 'body',
is_html: false,
},
});
expect(sendRes.ok()).toBe(false);
const afterSend = await getAddressSender(request, address);
expect(afterSend.balance).toBe(0);
expect(afterSend.enabled).toBe(0);
} finally {
await deleteAddress(request, jwt);
}
});
test('send after admin deletion auto-initializes a fresh sender row', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'send-access-deleted');
try {
await requestSendAccess(request, jwt);
const sender = await getAddressSender(request, address);
await deleteAddressSender(request, sender.id);
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: 'E2E',
to_name: 'E2E',
to_mail: 'recipient@test.example.com',
subject: `E2E reinit ${Date.now()}`,
content: 'body',
is_html: false,
},
});
expect(sendRes.ok()).toBe(true);
// A fresh row should exist with the default balance decremented by 1.
const recreated = await getAddressSender(request, address);
expect(recreated.enabled).toBe(1);
expect(recreated.balance).toBe(9);
} finally {
await deleteAddress(request, jwt);
}
});
test('request send access does not falsely succeed when quota is already exhausted', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'sendexh');
try {
await requestSendAccess(request, jwt);
const sender = await getAddressSender(request, address);
await updateAddressSender(request, {
address,
address_id: sender.id,
balance: 0,
enabled: true,
});
const retryRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(retryRes.status()).toBe(400);
const retryBody = await retryRes.text();
expect(retryBody).toContain('Already');
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

@@ -0,0 +1,517 @@
import { test, expect, APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
WORKER_URL_SEND_MAIL_DOMAIN,
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
requestSendAccess,
onMailpitMessage,
} 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: {},
};
const DISABLED_LIMIT_CONFIG = {
dailyEnabled: false,
monthlyEnabled: false,
dailyLimit: null as number | null,
monthlyLimit: null as number | null,
};
async function saveLimitConfig(
request: APIRequestContext,
sendMailLimitConfig: Record<string, unknown>
) {
return request.post(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
data: { ...DEFAULT_ACCOUNT_SETTINGS, sendMailLimitConfig },
});
}
async function resetLimitConfig(request: APIRequestContext) {
const res = await saveLimitConfig(request, DISABLED_LIMIT_CONFIG);
expect(res.ok()).toBe(true);
}
async function sendOneMail(
request: APIRequestContext,
jwt: string,
tag: string,
opts: { expectDelivery?: boolean; lang?: string } = {}
) {
const { expectDelivery = true, lang } = opts;
const subject = `limit-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const headers: Record<string, string> = { Authorization: `Bearer ${jwt}` };
if (lang) headers['x-lang'] = lang;
let listener: ReturnType<typeof onMailpitMessage> | undefined;
if (expectDelivery) {
listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
}
const res = await request.post(`${WORKER_URL}/api/send_mail`, {
headers,
data: {
from_name: 'Limit E2E',
to_name: 'Recipient',
to_mail: 'recipient@test.example.com',
subject,
content: `Limit test body ${tag}`,
is_html: false,
},
});
return { res, listener, subject };
}
async function probeLimitBaseline(
request: APIRequestContext,
jwt: string,
config: {
dailyEnabled: boolean;
monthlyEnabled: boolean;
dailyLimit: number | null;
monthlyLimit: number | null;
},
subjectPrefix: string,
maxProbeLimit: number = 50
): Promise<number> {
for (let limit = 1; limit <= maxProbeLimit; limit++) {
const save = await saveLimitConfig(request, {
...config,
dailyLimit: config.dailyEnabled ? limit : null,
monthlyLimit: config.monthlyEnabled ? limit : null,
});
expect(save.ok()).toBe(true);
const probe = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: 'probe',
to_name: '',
to_mail: 'recipient@test.example.com',
subject: `${subjectPrefix}-${limit}-${Date.now()}`,
content: 'probe',
is_html: false,
},
});
if (probe.ok()) {
return limit;
}
}
throw new Error(`Failed to probe send mail limit baseline within ${maxProbeLimit}`);
}
async function probeDailyBaseline(
request: APIRequestContext,
jwt: string
): Promise<number> {
return probeLimitBaseline(request, jwt, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: 1,
monthlyLimit: null,
}, 'probe-daily');
}
async function probeMonthlyBaseline(
request: APIRequestContext,
jwt: string
): Promise<number> {
return probeLimitBaseline(request, jwt, {
dailyEnabled: false,
monthlyEnabled: true,
dailyLimit: null,
monthlyLimit: 1,
}, 'probe-monthly');
}
test.describe('Send Mail Limit', () => {
test.beforeEach(async ({ request }) => {
await deleteAllMailpitMessages(request);
await resetLimitConfig(request);
});
test.afterEach(async ({ request }) => {
await resetLimitConfig(request);
});
test('save + read roundtrip preserves all fields', async ({ request }) => {
const config = {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: 7,
monthlyLimit: 1234,
};
const save = await saveLimitConfig(request, config);
expect(save.ok()).toBe(true);
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
});
expect(read.ok()).toBe(true);
const body = await read.json();
expect(body.sendMailLimitConfig).toEqual(config);
});
test('disabled flags coerce numeric limits to null', async ({ request }) => {
const save = await saveLimitConfig(request, {
dailyEnabled: false,
monthlyEnabled: false,
dailyLimit: 10,
monthlyLimit: 20,
});
expect(save.ok()).toBe(true);
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
});
const body = await read.json();
expect(body.sendMailLimitConfig).toEqual(DISABLED_LIMIT_CONFIG);
});
test('minus one is accepted as unlimited', async ({ request }) => {
const config = {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: -1,
monthlyLimit: -1,
};
const save = await saveLimitConfig(request, config);
expect(save.ok()).toBe(true);
const read = await request.get(`${WORKER_URL}/admin/account_settings`, {
headers: ADMIN_HEADERS,
});
const body = await read.json();
expect(body.sendMailLimitConfig).toEqual(config);
});
test('invalid payloads rejected with 400', async ({ request }) => {
const cases: Array<Record<string, unknown>> = [
{ dailyEnabled: 'yes', monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: -2, monthlyLimit: null },
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: 1.5, monthlyLimit: null },
{ dailyEnabled: true, monthlyEnabled: false, dailyLimit: null, monthlyLimit: null },
{ dailyEnabled: false, monthlyEnabled: true, dailyLimit: null, monthlyLimit: null },
];
for (const bad of cases) {
const res = await saveLimitConfig(request, bad);
expect(res.status(), `payload: ${JSON.stringify(bad)}`).toBe(400);
}
});
test('disabled limit allows unlimited sends', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-off');
await requestSendAccess(request, jwt);
await resetLimitConfig(request);
for (let i = 0; i < 3; i++) {
const { res, listener } = await sendOneMail(request, jwt, `off${i}`);
expect(res.ok()).toBe(true);
await listener!.message;
}
await deleteAddress(request, jwt);
});
test('zero limit blocks sending immediately', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-zero');
await requestSendAccess(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: 0,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
const { res } = await sendOneMail(request, jwt, 'zero', {
expectDelivery: false,
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
test('daily limit blocks once reached and returns English message', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-daily');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const allowed = 2;
const limit = baseline + allowed;
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: limit,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
for (let i = 0; i < allowed; i++) {
const { res, listener } = await sendOneMail(request, jwt, `d${i}`);
expect(res.ok(), `send #${i} should succeed`).toBe(true);
await listener!.message;
}
const { res: blocked } = await sendOneMail(request, jwt, 'd-over', {
expectDelivery: false,
});
expect(blocked.ok()).toBe(false);
const text = await blocked.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
test('monthly limit blocks once reached', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-monthly');
await requestSendAccess(request, jwt);
const baseline = await probeMonthlyBaseline(request, jwt);
const allowed = 2;
const limit = baseline + allowed;
const save = await saveLimitConfig(request, {
dailyEnabled: false,
monthlyEnabled: true,
dailyLimit: null,
monthlyLimit: limit,
});
expect(save.ok()).toBe(true);
for (let i = 0; i < allowed; i++) {
const { res, listener } = await sendOneMail(request, jwt, `m${i}`);
expect(res.ok(), `send #${i} should succeed`).toBe(true);
await listener!.message;
}
const { res: blocked } = await sendOneMail(request, jwt, 'm-over', {
expectDelivery: false,
});
expect(blocked.ok()).toBe(false);
const text = await blocked.text();
expect(text).toContain('Server monthly send quota has been reached');
await deleteAddress(request, jwt);
});
test('zh-lang header returns Chinese daily limit message', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-zh');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: baseline,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
const { res } = await sendOneMail(request, jwt, 'zh-over', {
expectDelivery: false,
lang: 'zh',
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('服务器今日发信次数已达上限');
await deleteAddress(request, jwt);
});
test('validation failures (missing subject) do not consume quota', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-noconsume');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: baseline + 1,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
// Empty subject → rejected by validation BEFORE the counter increments.
const badRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: '',
to_name: '',
to_mail: 'recipient@test.example.com',
subject: '',
content: 'no subject',
is_html: false,
},
});
expect(badRes.ok()).toBe(false);
const { res, listener } = await sendOneMail(request, jwt, 'after-bad');
expect(res.ok()).toBe(true);
await listener!.message;
await deleteAddress(request, jwt);
});
test('both daily + monthly enabled: tighter daily limit wins', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-both');
await requestSendAccess(request, jwt);
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: baseline,
monthlyLimit: baseline + 10_000,
});
expect(save.ok()).toBe(true);
const { res } = await sendOneMail(request, jwt, 'both-over', {
expectDelivery: false,
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
test('minus one means unlimited at runtime', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-unlimited');
await requestSendAccess(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: -1,
monthlyLimit: -1,
});
expect(save.ok()).toBe(true);
for (let i = 0; i < 3; i++) {
const { res, listener } = await sendOneMail(request, jwt, `unl${i}`);
expect(res.ok(), `send #${i} should succeed`).toBe(true);
await listener!.message;
}
await deleteAddress(request, jwt);
});
test('/admin/send_mail_by_binding returns 400 when SEND_MAIL binding is missing', async ({ request }) => {
const res = await request.post(`${WORKER_URL}/admin/send_mail_by_binding`, {
headers: ADMIN_HEADERS,
data: {
from: 'admin@test.example.com',
to: ['recipient@test.example.com'],
subject: 'no-binding',
text: 'body',
},
});
expect(res.status()).toBe(400);
});
test('/admin/send_mail_by_binding returns 200 when domain is allowed', 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',
to: ['recipient@test.example.com'],
subject: `send-mail-domain-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,
data: {
from: 'admin@blocked.example.com',
to: ['recipient@test.example.com'],
subject: `send-mail-domain-blocked-${Date.now()}`,
text: 'body',
},
});
expect(res.status()).toBe(400);
expect(await res.text()).toContain('Please enable SEND_MAIL for this domain first');
});
test('daily and monthly counters both increment on successful send', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'limit-both-inc');
await requestSendAccess(request, jwt);
const dailyBaseline = await probeDailyBaseline(request, jwt);
const monthlyBaseline = await probeMonthlyBaseline(request, jwt);
// Give plenty of headroom so sends succeed.
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: true,
dailyLimit: dailyBaseline + 10,
monthlyLimit: monthlyBaseline + 10,
});
expect(save.ok()).toBe(true);
const { res, listener } = await sendOneMail(request, jwt, 'inc');
expect(res.ok()).toBe(true);
await listener!.message;
// Re-probe to confirm both counters moved forward after the successful send.
const dailyAfter = await probeDailyBaseline(request, jwt);
expect(dailyAfter).toBeGreaterThanOrEqual(dailyBaseline + 1);
const monthlyAfter = await probeMonthlyBaseline(request, jwt);
expect(monthlyAfter).toBeGreaterThanOrEqual(monthlyBaseline + 1);
await deleteAddress(request, jwt);
});
test('admin /admin/send_mail also respects daily limit', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'limit-admin');
await requestSendAccess(request, jwt);
// Probe via a user-facing send to establish baseline.
const baseline = await probeDailyBaseline(request, jwt);
const save = await saveLimitConfig(request, {
dailyEnabled: true,
monthlyEnabled: false,
dailyLimit: baseline,
monthlyLimit: null,
});
expect(save.ok()).toBe(true);
const res = await request.post(`${WORKER_URL}/admin/send_mail`, {
headers: ADMIN_HEADERS,
data: {
from_name: '',
from_mail: address,
to_name: '',
to_mail: 'recipient@test.example.com',
subject: `admin-over-${Date.now()}`,
content: 'admin blocked body',
is_html: false,
},
});
expect(res.ok()).toBe(false);
const text = await res.text();
expect(text).toContain('Server daily send quota has been reached');
await deleteAddress(request, jwt);
});
});

View File

@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import {
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
onMailpitMessage,
WORKER_URL,
} from '../../fixtures/test-helpers';
test.describe('Send Mail via SMTP', () => {
test.beforeEach(async ({ request }) => {
await deleteAllMailpitMessages(request);
});
test('send HTML email and verify in Mailpit', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'sender-test');
const subject = `E2E Test ${Date.now()}`;
const htmlContent = '<h1>Hello</h1><p>This is an <b>E2E test</b> email.</p>';
// Start listening for the message BEFORE sending
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
// Send mail via worker API
const sendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
from_name: 'E2E Sender',
to_name: 'E2E Recipient',
to_mail: 'recipient@test.example.com',
subject,
content: htmlContent,
is_html: true,
},
});
expect(sendRes.ok()).toBe(true);
// Wait for Mailpit WebSocket "new" event — no polling
const mail = await listener.message;
expect(mail.From.Address).toBe(address);
expect(mail.To[0].Address).toBe('recipient@test.example.com');
// Balance should auto-initialize to 10 and then decrement to 9 after sending.
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.send_balance).toBe(9);
// Cleanup
await deleteAddress(request, jwt);
});
});

View File

@@ -0,0 +1,198 @@
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 CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
let originalCreateAddressStoredEnabled: boolean | undefined;
let originalEnvOffStoredEnabled: boolean | undefined;
async function getAccountSettings(request: any, workerUrl: string) {
const res = await request.get(`${workerUrl}/admin/account_settings`);
expect(res.ok()).toBe(true);
return await res.json();
}
function buildAccountSettingsPayload(
current: any,
addressCreationSettings?: { enableSubdomainMatch?: boolean | null },
overrides: Record<string, unknown> = {}
) {
return {
blockList: current.blockList || [],
sendBlockList: current.sendBlockList || [],
verifiedAddressList: current.verifiedAddressList || [],
fromBlockList: current.fromBlockList || [],
noLimitSendAddressList: current.noLimitSendAddressList || [],
emailRuleSettings: current.emailRuleSettings || {},
...(typeof addressCreationSettings !== 'undefined'
? { addressCreationSettings }
: {}),
...overrides,
};
}
async function saveSubdomainMatchSetting(
request: any,
workerUrl: string,
enableSubdomainMatch: boolean | null
) {
const current = await getAccountSettings(request, workerUrl);
const res = await request.post(`${workerUrl}/admin/account_settings`, {
data: buildAccountSettingsPayload(current, {
enableSubdomainMatch,
}),
});
expect(res.ok()).toBe(true);
}
async function restoreSubdomainMatchSetting(
request: any,
workerUrl: string,
originalValue: boolean | undefined
) {
if (typeof originalValue === 'boolean') {
await saveSubdomainMatchSetting(request, workerUrl, originalValue);
return;
}
await saveSubdomainMatchSetting(request, workerUrl, null);
}
test.describe('Create Address Subdomain Match', () => {
test.beforeAll(async ({ request }) => {
const createAddressSettings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
originalCreateAddressStoredEnabled = createAddressSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
if (WORKER_URL_ENV_OFF) {
const envOffSettings = await getAccountSettings(request, WORKER_URL_ENV_OFF);
originalEnvOffStoredEnabled = envOffSettings.addressCreationSubdomainMatchStatus?.storedEnabled;
}
});
test.afterEach(async ({ request }) => {
await restoreSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, originalCreateAddressStoredEnabled);
if (WORKER_URL_ENV_OFF) {
await restoreSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, originalEnvOffStoredEnabled);
}
});
test('admin can clear override and return to env fallback', async ({ request }) => {
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, null);
const settings = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
expect(settings.addressCreationSubdomainMatchStatus?.storedEnabled).toBeUndefined();
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: `subenvfb${Date.now()}`, domain: SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
});
test('invalid addressCreationSettings payload does not partially persist earlier settings', async ({ request }) => {
const current = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
const uniqueBlockedKeyword = `should-not-persist-${Date.now()}`;
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/account_settings`, {
data: buildAccountSettingsPayload(
current,
{ enableSubdomainMatch: 'invalid-value' as any },
{
blockList: [...(current.blockList || []), uniqueBlockedKeyword],
}
),
});
expect(res.status()).toBe(400);
const after = await getAccountSettings(request, CREATE_ADDRESS_WORKER_URL);
expect(after.blockList || []).toEqual(current.blockList || []);
expect(after.addressCreationSubdomainMatchStatus?.storedEnabled).toBe(
current.addressCreationSubdomainMatchStatus?.storedEnabled
);
});
test('persisted false still keeps exact match only', 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 },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
});
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, true);
const adminName = `subdomain-admin-${Date.now()}`;
const adminRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: adminName, domain: SUBDOMAIN },
});
expect(adminRes.ok()).toBe(true);
const adminBody = await adminRes.json();
expect(adminBody.address).toContain(`@${SUBDOMAIN}`);
expect(adminBody.address_id).toBeGreaterThan(0);
const userName = `subdomain-user-${Date.now()}`;
const userRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
data: { name: userName, domain: NESTED_SUBDOMAIN },
});
expect(userRes.ok()).toBe(true);
const userBody = await userRes.json();
expect(userBody.address).toContain(`@${NESTED_SUBDOMAIN}`);
expect(userBody.address_id).toBeGreaterThan(0);
const mixedCaseRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: `subcase${Date.now()}`, domain: MIXED_CASE_SUBDOMAIN },
});
expect(mixedCaseRes.ok()).toBe(true);
const mixedCaseBody = await mixedCaseRes.json();
expect(mixedCaseBody.address).toContain(`@${SUBDOMAIN}`);
const invalidRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: `subinvalid${Date.now()}`, domain: INVALID_LOOKALIKE_DOMAIN },
});
expect(invalidRes.ok()).toBe(false);
expect(await invalidRes.text()).toContain('Invalid domain');
const invalidEmptyPrefixRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: `subempty${Date.now()}`, domain: INVALID_EMPTY_PREFIX_DOMAIN },
});
expect(invalidEmptyPrefixRes.ok()).toBe(false);
expect(await invalidEmptyPrefixRes.text()).toContain('Invalid domain');
const invalidEmptyLabelRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: `sublabel${Date.now()}`, domain: INVALID_EMPTY_LABEL_DOMAIN },
});
expect(invalidEmptyLabelRes.ok()).toBe(false);
expect(await invalidEmptyLabelRes.text()).toContain('Invalid domain');
const invalidOverlongRes = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
data: { name: `sublong${Date.now()}`, domain: INVALID_OVERLONG_DOMAIN },
});
expect(invalidOverlongRes.ok()).toBe(false);
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
});
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 },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
});
});

View File

@@ -0,0 +1,88 @@
import { test, expect } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
seedTestMail,
deleteAddress,
} from '../../fixtures/test-helpers';
test.describe('Webhook Settings', () => {
test('get default webhook settings returns empty/disabled', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'webhook-get');
try {
const res = await request.get(`${WORKER_URL}/api/webhook/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.ok()).toBe(true);
const settings = await res.json();
expect(settings.enabled).toBeFalsy();
expect(settings.url).toBe('');
} finally {
await deleteAddress(request, jwt);
}
});
test('save and retrieve webhook settings', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'webhook-save');
try {
// Save webhook settings
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
enabled: true,
url: 'https://example.com/webhook',
method: 'POST',
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ from: '${from}', subject: '${subject}' }),
},
});
expect(saveRes.ok()).toBe(true);
const saveBody = await saveRes.json();
expect(saveBody.success).toBe(true);
// Retrieve and verify
const getRes = await request.get(`${WORKER_URL}/api/webhook/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(getRes.ok()).toBe(true);
const settings = await getRes.json();
expect(settings.enabled).toBe(true);
expect(settings.url).toBe('https://example.com/webhook');
expect(settings.method).toBe('POST');
expect(settings.headers).toBe(JSON.stringify({ 'Content-Type': 'application/json' }));
expect(settings.body).toBe(JSON.stringify({ from: '${from}', subject: '${subject}' }));
} finally {
await deleteAddress(request, jwt);
}
});
test('test webhook with unreachable URL returns error', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'webhook-fail');
try {
// Seed a mail so the test endpoint has raw data
await seedTestMail(request, address, {
subject: 'Webhook Fail Test',
from: 'sender@test.example.com',
text: 'This webhook should fail',
});
// Test webhook with unreachable URL — expect non-2xx response
const testRes = await request.post(`${WORKER_URL}/api/webhook/test`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
enabled: true,
url: 'http://unreachable.invalid/webhook',
method: 'POST',
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ from: '${from}' }),
},
});
expect(testRes.ok()).toBe(false);
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

@@ -0,0 +1,161 @@
import { test, expect } from '@playwright/test';
import http from 'node:http';
import {
WORKER_URL,
createTestAddress,
deleteAddress,
} from '../../fixtures/test-helpers';
/**
* Start a temporary HTTP server that records incoming requests.
* Returns the server, a promise that resolves with the first request body,
* and the URL to use as webhook target.
*/
async function startWebhookReceiver(): Promise<{
server: http.Server;
firstRequest: Promise<{ body: string; method: string; path: string; headers: http.IncomingHttpHeaders }>;
url: string;
}> {
let resolve: (val: any) => void;
const firstRequest = new Promise<any>((r) => { resolve = r; });
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
resolve({
body: Buffer.concat(chunks).toString('utf-8'),
method: req.method || '',
path: req.url || '',
headers: req.headers,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{"ok":true}');
});
});
// Use port 0 to let the OS assign a free port
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 webhook receiver port');
const boundPort = addr.port;
// In Docker network, e2e-runner container hostname is "e2e-runner"
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
return { server, firstRequest, url: `http://${hostname}:${boundPort}/webhook` };
}
test.describe('Webhook — triggered on incoming mail', () => {
let jwt: string;
let address: string;
test.beforeAll(async ({ request }) => {
({ jwt, address } = await createTestAddress(request, 'webhook-trigger'));
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('webhook is called with correct payload when mail arrives', async ({ request }) => {
const { server, firstRequest, url } = await startWebhookReceiver();
try {
// Configure user webhook
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
enabled: true,
url,
method: 'POST',
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
from: '${from}',
to: '${to}',
subject: '${subject}',
}),
},
});
expect(saveRes.ok()).toBe(true);
// Send incoming mail via receive_mail endpoint
const from = `webhook-sender@test.example.com`;
const subject = `Webhook Test ${Date.now()}`;
const messageId = `<webhook-${Date.now()}@test>`;
const raw = [
`From: ${from}`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
``,
`Webhook trigger test body`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
expect(res.ok()).toBe(true);
// Wait for webhook to be called
const received = await firstRequest;
expect(received.method).toBe('POST');
expect(received.path).toBe('/webhook');
const payload = JSON.parse(received.body);
expect(payload.from).toContain('webhook-sender@test.example.com');
expect(payload.to).toBe(address);
expect(payload.subject).toBe(subject);
} finally {
server.close();
}
});
test('webhook is NOT called when disabled', async ({ request }) => {
const { server, firstRequest, url } = await startWebhookReceiver();
try {
// Disable webhook
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
enabled: false,
url,
method: 'POST',
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ from: '${from}' }),
},
});
expect(saveRes.ok()).toBe(true);
// Send incoming mail
const subject = `Webhook Disabled ${Date.now()}`;
const messageId = `<webhook-off-${Date.now()}@test>`;
const raw = [
`From: sender@test.example.com`,
`To: ${address}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`MIME-Version: 1.0`,
`Content-Type: text/plain; charset=utf-8`,
``,
`Should not trigger webhook`,
].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);
// Webhook should NOT be called — wait briefly then verify timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 3_000)
);
await expect(
Promise.race([firstRequest, timeoutPromise])
).rejects.toThrow('timeout');
} finally {
server.close();
}
});
});

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import {
FRONTEND_URL,
createTestAddress,
seedTestMail,
deleteAddress,
} from '../../fixtures/test-helpers';
import { request as apiRequest } from '@playwright/test';
test.describe('Inbox Browser Flow', () => {
test('login via JWT, view inbox, open email', async ({ page }) => {
// Create API context for setup
const api = await apiRequest.newContext();
let jwt: string | undefined;
try {
const created = await createTestAddress(api, 'inbox-browser');
jwt = created.jwt;
const address = created.address;
// Seed an email
const subject = `Browser Test ${Date.now()}`;
await seedTestMail(api, address, {
subject,
html: '<h1>Welcome</h1><p>This is a <b>browser test</b> email.</p>',
});
// Login via JWT query param with /en/ path to force English locale
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// The mail subject should be visible in the inbox list item
const mailItem = page.getByRole('listitem').getByText(subject);
await expect(mailItem).toBeVisible({ timeout: 10_000 });
// Click to open the email
await mailItem.click();
// Verify the email detail panel shows the subject as a heading
// (n-card-header wraps n-card-header__main, both match heading role — use .first())
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 5_000 });
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
});

View File

@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { FRONTEND_URL } from '../../fixtures/test-helpers';
const installLocaleInitScript = async (page: Page, locales: string[], preferredLocale: string | null = null) => {
await page.addInitScript(
({ locales: initialLocales, preferredLocale: initialPreferredLocale }: { locales: string[]; preferredLocale: string | null }) => {
const localeInitStorageKey = '__localeInitDone';
if (!window.sessionStorage.getItem(localeInitStorageKey)) {
window.localStorage.removeItem('preferredLocale');
if (initialPreferredLocale) {
window.localStorage.setItem('preferredLocale', initialPreferredLocale);
}
window.sessionStorage.setItem(localeInitStorageKey, '1');
}
Object.defineProperty(window.navigator, 'language', {
configurable: true,
get: () => initialLocales[0],
});
Object.defineProperty(window.navigator, 'languages', {
configurable: true,
get: () => initialLocales,
});
},
{ locales, preferredLocale },
);
};
const selectLanguage = async (page: Page, selectTrigger: Locator, optionLabel: string) => {
await selectTrigger.click();
const option = page.locator('.n-dropdown-option, .n-dropdown-option-body').filter({ hasText: optionLabel }).first();
await expect(option).toBeVisible();
await option.click();
};
test.describe('Locale switching', () => {
test('keeps default route in Chinese while persisting browser language preference', async ({ page }) => {
await installLocaleInitScript(page, ['es-ES', 'en-US']);
await page.goto(`${FRONTEND_URL}/`);
await expect(page).toHaveURL(`${FRONTEND_URL}/`);
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('preferredLocale'))).toBe('es');
await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('zh');
});
test('mobile drawer switch updates locale route and persisted preference', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 844 });
await installLocaleInitScript(page, ['zh-CN']);
await page.goto(`${FRONTEND_URL}/`);
await page.getByRole('button', { name: /菜单|Menu/i }).click();
const drawerLocaleDropdown = page.locator('.n-drawer').getByRole('button', { name: /中文|English|Español|Português|日本語|Deutsch/ }).first();
await selectLanguage(page, drawerLocaleDropdown, 'Deutsch');
await expect(page).toHaveURL(`${FRONTEND_URL}/de/`);
await expect.poll(() => page.evaluate(() => window.localStorage.getItem('preferredLocale'))).toBe('de');
await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('de');
});
});

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test';
import { request as apiRequest } from '@playwright/test';
import { createHash } from 'crypto';
import { WORKER_URL, FRONTEND_URL } from '../../fixtures/test-helpers';
const TEST_USER_EMAIL = `passkey-browser-${Date.now()}@test.example.com`;
const TEST_USER_PASSWORD = 'browser-test-pwd-123';
// Frontend hashes passwords with SHA-256 before sending to the API.
// Register with the hashed password so UI login matches.
const HASHED_PASSWORD = createHash('sha256').update(TEST_USER_PASSWORD).digest('hex');
test.describe('Passkey Browser Flow', () => {
let userJwt: string;
test.beforeAll(async () => {
const api = await apiRequest.newContext();
try {
// Enable user registration
await api.post(`${WORKER_URL}/admin/user_settings`, {
data: { enable: true, enableMailVerify: false },
});
// Register user with hashed password (matching frontend behavior)
await api.post(`${WORKER_URL}/user_api/register`, {
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
});
// Login to get JWT for localStorage injection
const loginRes = await api.post(`${WORKER_URL}/user_api/login`, {
data: { email: TEST_USER_EMAIL, password: HASHED_PASSWORD },
});
const body = await loginRes.json();
userJwt = body.jwt;
} finally {
await api.dispose();
}
});
test('register passkey, then login with passkey', async ({ page, context }) => {
// Set up virtual authenticator via CDP
const cdp = await context.newCDPSession(page);
await cdp.send('WebAuthn.enable');
const { authenticatorId } = await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
},
});
try {
// === Step 1: Login via localStorage injection ===
// Inject JWT into localStorage to skip UI login flow.
await page.goto(`${FRONTEND_URL}/en/`);
// VueUse's useStorage with string default stores raw strings (no JSON)
await page.evaluate((jwt) => {
localStorage.setItem('userJwt', jwt);
}, userJwt);
await page.goto(`${FRONTEND_URL}/en/user`);
// Wait for user settings to load (shows user email)
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
// === Step 2: Click "User Settings" tab ===
await page.getByText('User Settings').click();
// === Step 3: Create a passkey ===
await page.getByRole('button', { name: 'Create Passkey' }).click();
// Fill passkey name in the modal
const createModal = page.locator('.n-dialog');
await expect(createModal).toBeVisible({ timeout: 5_000 });
await createModal.getByRole('textbox').fill('E2E Test Passkey');
// Click the Create Passkey button inside the modal
await createModal.getByRole('button', { name: 'Create Passkey' }).click();
// Wait for success — modal should close
await expect(createModal).not.toBeVisible({ timeout: 10_000 });
// === Step 4: Verify passkey appears in the list ===
await page.getByRole('button', { name: 'Show Passkey List' }).click();
const listModal = page.locator('.n-card-header:has-text("Show Passkey List")').locator('..');
await expect(page.getByText('E2E Test Passkey')).toBeVisible({ timeout: 5_000 });
// Close the list modal
await page.keyboard.press('Escape');
// === Step 5: Logout ===
await page.getByRole('button', { name: 'Logout' }).click();
const logoutModal = page.locator('.n-dialog');
await expect(logoutModal).toBeVisible({ timeout: 5_000 });
await logoutModal.getByRole('button', { name: 'Logout' }).click();
// Wait for logout to complete and navigate to user page
await page.waitForTimeout(2000);
await page.goto(`${FRONTEND_URL}/en/user`);
// === Step 6: Login with passkey ===
const passkeyBtn = page.getByRole('button', { name: 'Login with Passkey' });
await expect(passkeyBtn).toBeVisible({ timeout: 10_000 });
await passkeyBtn.click();
// Virtual authenticator handles the WebAuthn ceremony automatically
// Wait for login to complete — user email should appear
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
} finally {
await cdp.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
await cdp.detach();
}
});
});

View File

@@ -0,0 +1,105 @@
import { test, expect } from '@playwright/test';
import {
FRONTEND_URL,
createTestAddress,
seedTestMail,
deleteAddress,
deleteAllMailpitMessages,
requestSendAccess,
} from '../../fixtures/test-helpers';
import { request as apiRequest } from '@playwright/test';
test.describe('Reply HTML & XSS Sanitization', () => {
test('reply to HTML email — XSS payloads stripped, HTML preserved', async ({ page }) => {
const api = await apiRequest.newContext();
let jwt: string | undefined;
try {
await deleteAllMailpitMessages(api);
const created = await createTestAddress(api, 'reply-xss');
jwt = created.jwt;
const address = created.address;
// Request send access so Reply can navigate to compose form
await requestSendAccess(api, jwt);
// Seed email with XSS payloads embedded in HTML
const xssHtml = [
'<div>',
' <h1>Important Message</h1>',
' <p>Please review this content.</p>',
' <script>alert("xss")</script>',
' <img src=x onerror="alert(1)">',
' <a href="javascript:alert(2)">click me</a>',
' <p style="color:red">Styled paragraph</p>',
'</div>',
].join('\n');
await seedTestMail(api, address, {
subject: 'XSS Test Email',
html: xssHtml,
from: 'attacker@test.example.com',
});
// Single dialog handler with phase tracking.
// During email rendering, the mail viewer uses an unsandboxed iframe so
// inline event handlers like onerror may fire — we dismiss those.
// After clicking Reply, any dialog means the compose path failed to sanitize.
let inComposePhase = false;
let composeDialogAppeared = false;
page.on('dialog', async (dialog) => {
if (inComposePhase) composeDialogAppeared = true;
await dialog.dismiss();
});
// Login with English locale
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// Open the email (use listitem to avoid strict mode violation
// when detail panel also shows the subject)
const mailItem = page.getByRole('listitem').getByText('XSS Test Email');
await expect(mailItem).toBeVisible({ timeout: 10_000 });
await mailItem.click();
// Wait for Reply button to appear — signals email content has rendered
const replyButton = page.locator('button').filter({ hasText: /Reply/i }).first();
await expect(replyButton).toBeVisible({ timeout: 10_000 });
// Click Reply — from here on, dialogs indicate sanitization failure (#857)
inComposePhase = true;
await replyButton.click();
// In the reply compose area, check that the forwarded HTML is sanitized:
// - <script> tags should be removed
// - onerror attributes should be removed
// - javascript: URLs should be removed
const composeArea = page.locator('.ql-editor, [contenteditable], textarea').first();
await expect(composeArea).toBeVisible({ timeout: 5_000 });
// Use inputValue() for <textarea> (Vue v-model sets .value, not innerHTML),
// fall back to innerHTML() for contenteditable elements
const tagName = await composeArea.evaluate(el => el.tagName.toLowerCase());
const content = tagName === 'textarea'
? await composeArea.inputValue()
: await composeArea.innerHTML();
// Verify content is non-empty (guard against vacuous pass)
expect(content.length).toBeGreaterThan(0);
// XSS vectors must be stripped
expect(content).not.toContain('<script>');
expect(content).not.toContain('onerror');
expect(content).not.toContain('javascript:');
// No XSS dialog should have fired in the compose area
expect(composeDialogAppeared).toBe(false);
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
});

View File

@@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test';
import { FRONTEND_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
import { request as apiRequest } from '@playwright/test';
test.describe('Webhook Presets', () => {
test('selecting each preset fills valid settings', async ({ page, context }) => {
test.setTimeout(60_000);
const api = await apiRequest.newContext();
let jwt: string | undefined;
// Block popups (presets open doc URLs in new tabs)
context.on('page', (p) => p.close());
try {
const created = await createTestAddress(api, 'webhook-preset');
jwt = created.jwt;
// Login via JWT
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
// Click "Webhook Settings" in the sidebar menu
const webhookMenu = page.getByText('Webhook Settings');
await expect(webhookMenu).toBeVisible({ timeout: 10_000 });
await webhookMenu.click();
// Verify presets button is visible
const presetsBtn = page.getByRole('button', { name: 'Presets' });
await expect(presetsBtn).toBeVisible({ timeout: 5000 });
// Helper to get form field value by label text
const getFieldValue = async (label: string): Promise<string> => {
// Find the label, then get the sibling textbox in the same form row
const row = page.locator('div', { hasText: new RegExp(`^${label}$`) }).locator('..');
const textbox = row.getByRole('textbox');
return textbox.inputValue();
};
// Define expected presets and their key fields
const expectedPresets = [
{
name: 'Message Pusher',
urlPattern: 'msgpusher.com',
bodyKeys: ['token', 'title', 'description', 'content'],
},
{
name: 'Bark',
urlPattern: 'api.day.app',
bodyKeys: ['title', 'body', 'group'],
},
{
name: 'ntfy',
urlPattern: 'ntfy.sh',
bodyKeys: ['topic', 'title', 'message', 'tags'],
},
];
for (const preset of expectedPresets) {
// Open dropdown and select preset
await presetsBtn.click();
const option = page.locator('.n-dropdown-option', { hasText: preset.name });
await expect(option).toBeVisible({ timeout: 5_000 });
await option.click();
// Wait for dropdown to close, then for URL field to contain preset pattern
await expect(option).toBeHidden({ timeout: 5_000 });
await page.waitForTimeout(500);
const allTextboxes = page.getByRole('textbox');
const count = await allTextboxes.count();
// Find URL, HEADERS, BODY values by reading all textboxes
let urlValue = '';
let headersValue = '';
let bodyValue = '';
for (let i = 0; i < count; i++) {
const val = await allTextboxes.nth(i).inputValue();
if (val.includes(preset.urlPattern)) {
urlValue = val;
} else if (val.includes('Content-Type')) {
headersValue = val;
} else if (val.includes('${subject}')) {
bodyValue = val;
}
}
// Verify URL
expect(urlValue, `${preset.name}: URL should contain ${preset.urlPattern}`).toContain(preset.urlPattern);
// Verify HEADERS is valid JSON with Content-Type
expect(headersValue, `${preset.name}: HEADERS should not be empty`).toBeTruthy();
const headers = JSON.parse(headersValue);
expect(headers, `${preset.name}: headers should have Content-Type`).toHaveProperty('Content-Type', 'application/json');
// Verify BODY is valid JSON with expected keys
expect(bodyValue, `${preset.name}: BODY should not be empty`).toBeTruthy();
const body = JSON.parse(bodyValue);
for (const key of preset.bodyKeys) {
expect(body, `${preset.name}: body should have key "${key}"`).toHaveProperty(key);
}
}
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
});

View File

@@ -0,0 +1,274 @@
import { test, expect } from '@playwright/test';
import { ImapFlow } from 'imapflow';
import { createTestAddress, seedTestMail, sendTestMail, deleteAddress, deleteAllMailpitMessages, onMailpitMessage } from '../../fixtures/test-helpers';
const IMAP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
const IMAP_PORT = parseInt(process.env.SMTP_PROXY_IMAP_PORT || '11143', 10);
function createClient(user: string, pass: string) {
return new ImapFlow({
host: IMAP_HOST,
port: IMAP_PORT,
secure: false,
auth: { user, pass },
logger: false,
});
}
test.describe('IMAP Proxy', () => {
let jwt: string;
let address: string;
test.beforeAll(async ({ request }) => {
({ jwt, address } = await createTestAddress(request, 'imap-e2e'));
await seedTestMail(request, address, { subject: 'IMAP Test 1', text: 'First test email' });
await seedTestMail(request, address, { subject: 'IMAP Test 2', text: 'Second test email' });
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('login with JWT token', async () => {
const client = createClient(address, jwt);
await client.connect();
expect(client.usable).toBe(true);
await client.logout();
});
test('login with wrong password fails', async () => {
const client = createClient(address, 'wrong-password');
await expect(client.connect()).rejects.toThrow();
});
test('LIST returns INBOX', async () => {
const client = createClient(address, jwt);
await client.connect();
const mailboxes = await client.list();
const names = mailboxes.map(m => m.path);
expect(names).toContain('INBOX');
await client.logout();
});
test('SELECT INBOX returns message count', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
expect(client.mailbox).toBeTruthy();
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
} finally {
lock.release();
}
await client.logout();
});
test('STATUS returns MESSAGES and UIDNEXT', async () => {
const client = createClient(address, jwt);
await client.connect();
const status = await client.status('INBOX', { messages: true, uidNext: true });
expect(status.messages).toBeGreaterThanOrEqual(2);
expect(status.uidNext).toBeGreaterThan(0);
await client.logout();
});
test('FETCH headers returns Subject', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne('1', { headers: true });
const headers = msg.headers.toString();
expect(headers).toContain('Subject:');
} finally {
lock.release();
}
await client.logout();
});
test('FETCH body returns content', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne('1', { source: true });
expect(msg.source.length).toBeGreaterThan(0);
} finally {
lock.release();
}
await client.logout();
});
test('SEARCH ALL returns message numbers', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const results = await client.search({ all: true });
expect(results.length).toBeGreaterThanOrEqual(2);
} finally {
lock.release();
}
await client.logout();
});
test('STORE sets flags', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const result = await client.messageFlagsAdd('1', ['\\Seen']);
expect(result).toBe(true);
} finally {
lock.release();
}
await client.logout();
});
test('UID FETCH works', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
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]);
} finally {
lock.release();
}
await client.logout();
});
test('FETCH source contains valid MIME with Content-Type and seeded body', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne('1', { source: true, envelope: true });
const source = msg.source.toString('utf-8');
expect(source).toContain('Content-Type:');
expect(source).toContain('Subject:');
// No duplicate From headers (regression: getBodyFile returned full MIME)
const fromMatches = source.match(/^From:/gm);
expect(fromMatches).toHaveLength(1);
} finally {
lock.release();
}
await client.logout();
});
test('RFC822.SIZE matches actual source length', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne('1', { source: true, size: true });
const source = msg.source.toString('utf-8');
expect(msg.size).toBe(Buffer.byteLength(source, 'utf-8'));
} finally {
lock.release();
}
await client.logout();
});
test('FETCH all messages returns correct sequence numbers', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const messages: { seq: number; uid: number }[] = [];
for await (const msg of client.fetch('1:*', { uid: true })) {
messages.push({ seq: msg.seq, uid: msg.uid });
}
expect(messages.length).toBeGreaterThanOrEqual(2);
// Sequence numbers must be consecutive starting from 1
for (let i = 0; i < messages.length; i++) {
expect(messages[i].seq).toBe(i + 1);
}
// UIDs must be strictly ascending
for (let i = 1; i < messages.length; i++) {
expect(messages[i].uid).toBeGreaterThan(messages[i - 1].uid);
}
} finally {
lock.release();
}
await client.logout();
});
test('LIST returns SENT mailbox', async () => {
const client = createClient(address, jwt);
await client.connect();
const mailboxes = await client.list();
const names = mailboxes.map(m => m.path);
expect(names).toContain('SENT');
await client.logout();
});
test('SELECT INBOX includes UIDVALIDITY and UIDNEXT', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
expect(client.mailbox!.uidValidity).toBeGreaterThan(0);
expect(client.mailbox!.uidNext).toBeGreaterThan(0);
} finally {
lock.release();
}
await client.logout();
});
});
test.describe('IMAP Proxy — SENT mailbox', () => {
let jwt: string;
let address: string;
const sentSubject = `IMAP Sent Test ${Date.now()}`;
test.beforeAll(async ({ request }) => {
await deleteAllMailpitMessages(request);
({ jwt, address } = await createTestAddress(request, 'imap-sent'));
const listener = onMailpitMessage((m) => m.Subject === sentSubject);
await listener.ready;
await sendTestMail(request, address, {
to_mail: `recipient@test.example.com`,
subject: sentSubject,
content: 'E2E sent mail body',
});
await listener.message;
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('SELECT SENT returns message count', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('SENT');
try {
expect(client.mailbox).toBeTruthy();
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(1);
} finally {
lock.release();
}
await client.logout();
});
test('FETCH SENT source contains valid MIME', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('SENT');
try {
const msg = await client.fetchOne('1', { source: true, envelope: true });
const source = msg.source.toString('utf-8');
expect(source.length).toBeGreaterThan(50);
expect(source).toContain('Content-Type:');
expect(source).toContain('Subject:');
} finally {
lock.release();
}
await client.logout();
});
});

View File

@@ -0,0 +1,81 @@
import { test, expect } from '@playwright/test';
import { ImapFlow } from 'imapflow';
import { createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
const IMAP_TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls';
const IMAP_TLS_PORT = parseInt(process.env.SMTP_PROXY_TLS_IMAP_PORT || '11144', 10);
function createClient(user: string, pass: string) {
return new ImapFlow({
host: IMAP_TLS_HOST,
port: IMAP_TLS_PORT,
secure: false,
auth: { user, pass },
logger: false,
tls: { rejectUnauthorized: false },
});
}
test.describe('IMAP Proxy — STARTTLS', () => {
let jwt: string;
let address: string;
test.beforeAll(async ({ request }) => {
({ jwt, address } = await createTestAddress(request, 'imap-tls'));
await seedTestMail(request, address, { subject: 'IMAP TLS Test 1', text: 'First TLS test email' });
await seedTestMail(request, address, { subject: 'IMAP TLS Test 2', text: 'Second TLS test email' });
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('login with JWT over STARTTLS', async () => {
const client = createClient(address, jwt);
await client.connect();
expect(client.usable).toBe(true);
await client.logout();
});
test('login with wrong password fails over STARTTLS', async () => {
const client = createClient(address, 'wrong-password');
await expect(client.connect()).rejects.toThrow();
});
test('LIST returns INBOX over STARTTLS', async () => {
const client = createClient(address, jwt);
await client.connect();
const mailboxes = await client.list();
const names = mailboxes.map(m => m.path);
expect(names).toContain('INBOX');
await client.logout();
});
test('SELECT INBOX returns messages over STARTTLS', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
expect(client.mailbox).toBeTruthy();
expect(client.mailbox!.exists).toBeGreaterThanOrEqual(2);
} finally {
lock.release();
}
await client.logout();
});
test('FETCH source over STARTTLS contains valid MIME', async () => {
const client = createClient(address, jwt);
await client.connect();
const lock = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne('1', { source: true });
const source = msg.source.toString('utf-8');
expect(source).toContain('Content-Type:');
expect(source).toContain('Subject:');
} finally {
lock.release();
}
await client.logout();
});
});

View File

@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test';
import nodemailer from 'nodemailer';
import {
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
requestSendAccess,
onMailpitMessage,
WORKER_URL,
} from '../../fixtures/test-helpers';
const SMTP_HOST = process.env.SMTP_PROXY_HOST || 'smtp-proxy';
const SMTP_PORT = parseInt(process.env.SMTP_PROXY_SMTP_PORT || '8025', 10);
function createTransport(user: string, pass: string) {
return nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: false,
auth: { user, pass },
tls: { rejectUnauthorized: false },
});
}
test.describe('SMTP Proxy', () => {
let jwt: string;
let address: string;
test.beforeAll(async ({ request }) => {
await deleteAllMailpitMessages(request);
({ jwt, address } = await createTestAddress(request, 'smtp-e2e'));
await requestSendAccess(request, jwt);
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('send plain text email via SMTP', async ({ request }) => {
const subject = `SMTP Plain ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTransport(address, jwt);
const info = await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
text: 'Hello from SMTP E2E test',
});
expect(info.accepted).toContain('recipient@test.example.com');
const delivered = await listener.message;
expect(delivered.Subject).toBe(subject);
});
test('send HTML email via SMTP', async ({ request }) => {
const subject = `SMTP HTML ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTransport(address, jwt);
const info = await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
html: '<h1>Hello</h1><p>HTML E2E test</p>',
});
expect(info.accepted).toContain('recipient@test.example.com');
const delivered = await listener.message;
expect(delivered.Subject).toBe(subject);
});
test('auth with wrong password fails', async () => {
const transport = createTransport(address, 'wrong-password');
await expect(
transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject: 'Should fail',
text: 'This should not be sent',
})
).rejects.toThrow();
});
test('sent mail appears in sendbox API', async ({ request }) => {
const subject = `SMTP Sendbox ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTransport(address, jwt);
await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
text: 'Check sendbox',
});
await listener.message;
const res = await request.get(`${WORKER_URL}/api/sendbox?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(res.ok()).toBe(true);
const { results } = await res.json();
const found = results.some((r: any) => {
const raw = JSON.parse(r.raw);
return raw.subject === subject;
});
expect(found).toBe(true);
});
});

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test';
import nodemailer from 'nodemailer';
import {
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
requestSendAccess,
onMailpitMessage,
} from '../../fixtures/test-helpers';
const TLS_HOST = process.env.SMTP_PROXY_TLS_HOST || 'smtp-proxy-tls';
const TLS_SMTP_PORT = parseInt(process.env.SMTP_PROXY_TLS_SMTP_PORT || '8026', 10);
function createTlsTransport(user: string, pass: string) {
return nodemailer.createTransport({
host: TLS_HOST,
port: TLS_SMTP_PORT,
secure: false,
auth: { user, pass },
tls: { rejectUnauthorized: false },
requireTLS: true,
});
}
function createNoTlsTransport(user: string, pass: string) {
return nodemailer.createTransport({
host: TLS_HOST,
port: TLS_SMTP_PORT,
secure: false,
auth: { user, pass },
tls: { rejectUnauthorized: false },
});
}
test.describe('SMTP Proxy — STARTTLS', () => {
let jwt: string;
let address: string;
test.beforeAll(async ({ request }) => {
await deleteAllMailpitMessages(request);
({ jwt, address } = await createTestAddress(request, 'smtp-tls'));
await requestSendAccess(request, jwt);
});
test.afterAll(async ({ request }) => {
await deleteAddress(request, jwt);
});
test('send plain text email via STARTTLS', async () => {
const subject = `SMTP TLS Plain ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTlsTransport(address, jwt);
const info = await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
text: 'Hello from SMTP STARTTLS E2E test',
});
expect(info.accepted).toContain('recipient@test.example.com');
const delivered = await listener.message;
expect(delivered.Subject).toBe(subject);
});
test('send HTML email via STARTTLS', async () => {
const subject = `SMTP TLS HTML ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createTlsTransport(address, jwt);
const info = await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
html: '<h1>Hello</h1><p>STARTTLS HTML E2E test</p>',
});
expect(info.accepted).toContain('recipient@test.example.com');
const delivered = await listener.message;
expect(delivered.Subject).toBe(subject);
});
test('connection without STARTTLS still works', async () => {
const subject = `SMTP TLS NoForce ${Date.now()}`;
const listener = onMailpitMessage((m) => m.Subject === subject);
await listener.ready;
const transport = createNoTlsTransport(address, jwt);
const info = await transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject,
text: 'Hello without forced STARTTLS',
});
expect(info.accepted).toContain('recipient@test.example.com');
const delivered = await listener.message;
expect(delivered.Subject).toBe(subject);
});
test('auth with wrong password fails over STARTTLS', async () => {
const transport = createTlsTransport(address, 'wrong-password');
await expect(
transport.sendMail({
from: address,
to: 'recipient@test.example.com',
subject: 'Should fail',
text: 'This should not be sent',
})
).rejects.toThrow();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.0.4",
"version": "1.9.0",
"private": true,
"type": "module",
"scripts": {
@@ -10,44 +10,50 @@
"build:pages": "vite build -m pages --emptyOutDir",
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
"preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
"deploy:actions": "npm run build && wrangler pages deploy ./dist"
"deploy:actions": "npm run build && wrangler pages deploy ./dist",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest"
},
"dependencies": {
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^1.11.20",
"@vueuse/core": "^12.8.2",
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@simplewebauthn/browser": "13.2.2",
"@unhead/vue": "^2.1.13",
"@vueuse/core": "^14.2.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.11.0",
"axios": "^1.15.2",
"dompurify": "^3.4.1",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.42.0",
"postal-mime": "^2.4.4",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.4",
"vooks": "^0.2.12",
"vue": "^3.5.19",
"vue": "^3.5.33",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1"
"vue-i18n": "^11.4.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^5.2.4",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.3",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"wrangler": "^4.32.0"
"@vitejs/plugin-vue": "^6.0.6",
"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-plugin-wasm": "^3.6.0",
"vitest": "^3.2.4",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0",
"wrangler": "^4.86.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

4719
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
<script setup>
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
import { computed, onMounted } from 'vue'
import {
darkTheme,
} from 'naive-ui'
import { computed, onMounted, watchEffect } from 'vue'
import { useScript } from '@unhead/vue'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store'
@@ -8,22 +10,37 @@ import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue';
import Footer from './views/Footer.vue';
import { api } from './api'
import { getNaiveLocaleConfig } from './i18n/naive-locale'
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
const {
isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState()
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
const { locale } = useI18n({});
const { locale } = useI18n({ useScope: 'global' });
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
const isMobile = useIsMobile()
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
const showAd = computed(() => !isMobile.value && adClient && adSlot);
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
onMounted(async () => {
watchEffect(() => {
if (typeof document === 'undefined') return
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
})
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
})
}
onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
@@ -43,11 +60,6 @@ onMounted(async () => {
// check if google ad is enabled
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
});
(window.adsbygoogle = window.adsbygoogle || []).push({});
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
@@ -74,7 +86,7 @@ onMounted(async () => {
</script>
<template>
<n-config-provider :locale="localeConfig" :theme="theme">
<n-config-provider :locale="localeConfig.locale" :date-locale="localeConfig.dateLocale" :theme="theme">
<n-global-style />
<n-spin description="loading..." :show="loading">
<n-notification-provider container-style="margin-top: 60px;">

View File

@@ -3,6 +3,8 @@ import { h } from 'vue'
import axios from 'axios'
import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
@@ -20,23 +22,37 @@ const instance = axios.create({
const apiFetch = async (path, options = {}) => {
loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
// Skip auth headers whose value is empty / "undefined" / contains
// control chars (otherwise axios throws "Invalid character in header
// content" before the request is sent — see issue #1000).
const headers = {
'x-lang': i18n.global.locale.value,
'x-fingerprint': fingerprint,
'Content-Type': 'application/json',
};
const userTokenHeader = safeHeaderValue(options.userJwt || userJwt.value);
if (userTokenHeader) headers['x-user-token'] = userTokenHeader;
const userAccessHeader = safeHeaderValue(userSettings.value.access_token);
if (userAccessHeader) headers['x-user-access-token'] = userAccessHeader;
const customAuthHeader = safeHeaderValue(auth.value);
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
const adminAuthHeader = safeHeaderValue(adminAuth.value);
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
const authorizationHeader = safeBearerHeader(jwt.value);
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
const response = await instance.request(path, {
method: options.method || 'GET',
data: options.body || null,
headers: {
'x-lang': i18n.global.locale.value,
'x-user-token': options.userJwt || userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'Authorization': `Bearer ${jwt.value}`,
'Content-Type': 'application/json',
},
headers,
});
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
}
if (response.status === 401 && openSettings.value.auth) {
if (response.status === 401 && openSettings.value.needAuth) {
showAuth.value = true;
}
if (response.status >= 300) {
@@ -57,8 +73,9 @@ const apiFetch = async (path, options = {}) => {
const getOpenSettings = async (message, notification) => {
try {
const res = await api.fetch("/open_api/settings");
const domains = Array.isArray(res["domains"]) ? res["domains"] : [];
const domainLabels = res["domainLabels"] || [];
if (res["domains"]?.length < 1) {
if (domains.length < 1) {
message.error("No domains found, please check your worker settings");
}
Object.assign(openSettings.value, {
@@ -69,7 +86,8 @@ const getOpenSettings = async (message, notification) => {
maxAddressLen: res["maxAddressLen"] || 30,
needAuth: res["needAuth"] || false,
defaultDomains: res["defaultDomains"] || [],
domains: res["domains"].map((domain, index) => {
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
domains: domains.map((domain, index) => {
return {
label: domainLabels.length > index ? domainLabels[index] : domain,
value: domain
@@ -78,6 +96,7 @@ const getOpenSettings = async (message, notification) => {
adminContact: res["adminContact"] || "",
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
@@ -85,6 +104,11 @@ const getOpenSettings = async (message, notification) => {
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;

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

@@ -0,0 +1,243 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { useScopedI18n } from '@/i18n/app'
import { useMessage } from 'naive-ui'
import useClipboard from 'vue-clipboard3'
import { Copy } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
const props = defineProps({
showCopy: {
type: Boolean,
default: true,
},
size: {
type: String,
default: 'small',
},
})
const message = useMessage()
const { toClipboard } = useClipboard()
const {
jwt, settings, userJwt, isTelegram, openSettings, telegramApp
} = useGlobalState()
const { t } = useScopedI18n('components.AddressSelect')
const addressOptions = ref([])
const addressValue = ref(null)
const addressLoading = ref(false)
const localAddressCache = useLocalStorage("LocalAddressCache", [])
const optionValueMap = new Map()
const formatAddressLabel = (address) => {
if (!address) return address;
const domain = address.split('@')[1]
const domainLabel = openSettings.value.domains.find(
d => d.value === domain
)?.label;
if (!domainLabel) return address;
return address.replace('@' + domain, `@${domainLabel}`);
}
const parseJwtAddress = (curJwt) => {
try {
const payload = JSON.parse(
decodeURIComponent(
atob(curJwt.split(".")[1]
.replace(/-/g, "+").replace(/_/g, "/")
)
)
);
return payload.address;
} catch (e) {
return null;
}
}
const getOptionValue = (key, scope, payload, address) => {
if (optionValueMap.has(key)) {
const cached = optionValueMap.get(key)
cached.scope = scope
cached.payload = payload
cached.address = address
return cached
}
const value = { key, scope, payload, address }
optionValueMap.set(key, value)
return value
}
const buildLocalOptions = (excludeAddresses = new Set()) => {
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
localAddressCache.value.push(jwt.value)
}
const children = localAddressCache.value
.map((curJwt) => {
const address = parseJwtAddress(curJwt);
if (!address) return null;
if (excludeAddresses.has(address)) return null;
const label = formatAddressLabel(address);
const key = `local:${curJwt}`;
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
if (settings.value.address && address === settings.value.address) {
addressValue.value = option.value;
}
return option;
})
.filter(Boolean);
return children;
}
const buildUserOptions = async () => {
const children = [];
try {
const { results } = await api.fetch(`/user_api/bind_address`);
for (const row of results || []) {
const address = row.address || row.name;
if (!address) continue;
const label = formatAddressLabel(address);
const key = `user:${row.id}`;
const option = { label, value: getOptionValue(key, 'user', String(row.id), address), address };
if (settings.value.address && address === settings.value.address) {
addressValue.value = option.value;
}
children.push(option);
}
} catch (error) {
message.error(error.message || "error");
}
return children;
}
const buildTelegramOptions = async () => {
const children = [];
try {
const data = await api.fetch(`/telegram/get_bind_address`, {
method: 'POST',
body: JSON.stringify({
initData: telegramApp.value.initData
})
});
for (const row of data || []) {
if (!row?.address || !row?.jwt) continue;
const label = formatAddressLabel(row.address);
const key = `tg:${row.jwt}`;
const option = { label, value: getOptionValue(key, 'tg', row.jwt, row.address), address: row.address };
if (settings.value.address && row.address === settings.value.address) {
addressValue.value = option.value;
}
children.push(option);
}
} catch (error) {
message.error(error.message || "error");
}
return children;
}
const refreshAddressOptions = async () => {
addressLoading.value = true;
addressValue.value = null;
try {
if (isTelegram.value) {
const telegramChildren = await buildTelegramOptions();
addressOptions.value = telegramChildren;
return;
}
const groups = [];
if (userJwt.value) {
const userChildren = await buildUserOptions();
if (userChildren.length > 0) {
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
}
const userAddressSet = new Set(userChildren.map((item) => item.address));
const localChildren = buildLocalOptions(userAddressSet);
if (localChildren.length > 0) {
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
}
} else {
const localChildren = buildLocalOptions();
if (localChildren.length > 0) {
groups.push({ type: 'group', label: t('localAddresses'), children: localChildren });
}
}
addressOptions.value = groups;
} finally {
addressLoading.value = false;
}
}
const onAddressChange = async (value) => {
if (!value) return;
if (value.scope === 'local' || value.scope === 'tg') {
jwt.value = value.payload;
location.reload();
return;
}
if (value.scope === 'user') {
try {
const res = await api.fetch(`/user_api/bind_address_jwt/${value.payload}`);
if (!res?.jwt) {
message.error("jwt not found");
return;
}
jwt.value = res.jwt;
location.reload();
} catch (error) {
message.error(error.message || "error");
}
}
}
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
}
}
onMounted(async () => {
await refreshAddressOptions();
});
watch([userJwt, isTelegram, () => settings.value.address], async () => {
await refreshAddressOptions();
});
</script>
<template>
<n-flex class="address-row" align="center" justify="center" :wrap="true">
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
class="address-select" />
<slot name="actions" />
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</n-flex>
</template>
<style scoped>
.address-row {
width: 100%;
gap: 10px;
}
.address-select {
min-width: 220px;
max-width: 420px;
flex: 1 1 220px;
}
.address-copy {
flex: 0 0 auto;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,153 @@
<script setup>
import { computed } from 'vue';
import { useScopedI18n } from '@/i18n/app';
import { ContentCopyOutlined, LinkRound, CodeRound } from '@vicons/material';
import { useMessage } from 'naive-ui';
import { useGlobalState } from '../store';
const message = useMessage();
const { isDark } = useGlobalState();
// Dark mode: use Gmail's softer blue (#A8C7FA) for better readability
const alertThemeOverrides = computed(() => {
if (isDark.value) {
return {
colorSuccess: 'rgba(168, 199, 250, 0.15)',
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
iconColorSuccess: '#A8C7FA',
titleTextColorSuccess: '#A8C7FA',
}
}
return {}
});
const tagThemeOverrides = computed(() => {
if (isDark.value) {
return {
colorSuccess: 'rgba(168, 199, 250, 0.15)',
borderSuccess: '1px solid rgba(168, 199, 250, 0.3)',
textColorSuccess: '#A8C7FA',
}
}
return {}
});
const { t } = useScopedI18n('components.AiExtractInfo')
const props = defineProps({
metadata: {
type: String,
default: null
},
compact: {
type: Boolean,
default: false
}
});
const aiExtract = computed(() => {
if (!props.metadata) return null;
try {
const data = JSON.parse(props.metadata);
return data.ai_extract || null;
} catch (e) {
return null;
}
});
const typeLabel = computed(() => {
if (!aiExtract.value) return '';
const typeMap = {
auth_code: t('authCode'),
auth_link: t('authLink'),
service_link: t('serviceLink'),
subscription_link: t('subscriptionLink'),
other_link: t('otherLink'),
};
return typeMap[aiExtract.value.type] || '';
});
const typeIcon = computed(() => {
if (!aiExtract.value) return null;
const iconMap = {
auth_code: CodeRound,
auth_link: LinkRound,
service_link: LinkRound,
subscription_link: LinkRound,
other_link: LinkRound,
};
return iconMap[aiExtract.value.type] || null;
});
const isLink = computed(() => {
return aiExtract.value && aiExtract.value.type !== 'auth_code';
});
const displayText = computed(() => {
if (!aiExtract.value) return '';
// For auth_code, always show the raw result (verification code)
if (aiExtract.value.type === 'auth_code') {
return aiExtract.value.result;
}
// For links, prefer result_text as display label
return aiExtract.value.result_text || aiExtract.value.result;
});
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(aiExtract.value.result);
message.success(t('copySuccess'));
} catch (e) {
message.error(t('copyFailed'));
}
};
const openLink = () => {
if (isLink.value && aiExtract.value.result) {
window.open(aiExtract.value.result, '_blank');
}
};
</script>
<template>
<div v-if="aiExtract && aiExtract.result" class="ai-extract-info">
<n-alert v-if="!compact" type="success" closable :theme-overrides="alertThemeOverrides">
<template #icon>
<n-icon :component="typeIcon" />
</template>
<template #header>
{{ typeLabel }}
</template>
<n-space align="center">
<n-text v-if="aiExtract.type === 'auth_code'" strong style="font-size: 18px; font-family: monospace;">
{{ aiExtract.result }}
</n-text>
<n-ellipsis v-else style="max-width: 400px;">
{{ displayText }}
</n-ellipsis>
<n-button size="small" @click="copyToClipboard" tertiary>
<template #icon>
<n-icon :component="ContentCopyOutlined" />
</template>
</n-button>
<n-button v-if="isLink" size="small" @click="openLink" tertiary type="primary">
{{ t('open') }}
</n-button>
</n-space>
</n-alert>
<n-tag v-else type="success" @click="copyToClipboard" style="cursor: pointer;" size="small" :theme-overrides="tagThemeOverrides">
<template #icon>
<n-icon :component="typeIcon" />
</template>
<n-ellipsis style="max-width: 150px;">
{{ typeLabel }}: {{ displayText }}
</n-ellipsis>
</n-tag>
</div>
</template>
<style scoped>
.ai-extract-info {
margin-bottom: 10px;
}
</style>

View File

@@ -1,13 +1,15 @@
<script setup>
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled } from '@vicons/material'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
import MailContentRenderer from "./MailContentRenderer.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
const message = useMessage()
const isMobile = useIsMobile()
@@ -48,20 +50,44 @@ const props = defineProps({
default: (mail_id, filename, blob) => { },
required: false
},
showFilterInput: {
type: Boolean,
default: false,
required: false
},
})
const localFilterKeyword = ref('')
const {
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const data = ref([])
const rawData = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
// Computed property for filtered data (only filter current page)
const data = computed(() => {
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
return rawData.value;
}
const keyword = localFilterKeyword.value.toLowerCase();
return rawData.value.filter(mail => {
// Search in subject, text, message fields
const searchFields = [
mail.subject || '',
mail.text || '',
mail.message || ''
].map(field => field.toLowerCase());
return searchFields.some(field => field.includes(keyword));
});
})
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
@@ -112,54 +138,7 @@ const showMultiActionDelete = ref(false)
const multiActionDownloadZip = ref({})
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
const { t } = useI18n({
messages: {
en: {
success: 'Success',
autoRefresh: 'Auto Refresh',
refreshAfter: 'Refresh After {msg} Seconds',
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select mail",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
reply: 'Reply',
forwardMail: 'Forward',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail',
saveToS3: 'Save to S3',
multiAction: 'Multi Action',
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
prevMail: 'Previous',
nextMail: 'Next',
},
zh: {
success: '成功',
autoRefresh: '自动刷新',
refreshAfter: '{msg}秒后刷新',
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择邮件",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
forwardMail: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
prevMail: '上一封',
nextMail: '下一封',
}
}
});
const { t } = useScopedI18n('components.MailBox')
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every configAutoRefreshInterval seconds
@@ -196,7 +175,7 @@ const refresh = async () => {
pageSize.value, (page.value - 1) * pageSize.value
);
loading.value = true;
data.value = await Promise.all(results.map(async (item) => {
rawData.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
}));
@@ -245,30 +224,12 @@ const deleteMail = async () => {
};
const replyMail = async () => {
const emailRegex = /(.+?) <(.+?)>/;
let toMail = curMail.value.originalSource;
let toName = ""
const match = emailRegex.exec(curMail.value.source);
if (match) {
toName = match[1];
toMail = match[2];
}
Object.assign(sendMailModel.value, {
toName: toName,
toMail: toMail,
subject: `${t('reply')}: ${curMail.value.subject}`,
contentType: 'rich',
content: curMail.value.text ? `<p><br></p><blockquote>${curMail.value.text}</blockquote><p><br></p>` : '',
});
Object.assign(sendMailModel.value, buildReplyModel(curMail.value, t('reply')));
indexTab.value = 'sendmail';
};
const forwardMail = async () => {
Object.assign(sendMailModel.value, {
subject: `${t('forwardMail')}: ${curMail.value.subject}`,
contentType: curMail.value.message ? 'html' : 'text',
content: curMail.value.message || curMail.value.text,
});
Object.assign(sendMailModel.value, buildForwardModel(curMail.value, t('forwardMail')));
indexTab.value = 'sendmail';
};
@@ -369,7 +330,7 @@ onBeforeUnmount(() => {
<div>
<div v-if="!isMobile" class="left">
<div style="margin-bottom: 10px;">
<n-space v-if="multiActionMode">
<n-space v-if="multiActionMode" align="center">
<n-button @click="multiActionModeClick(false)" tertiary>
{{ t('cancelMultiAction') }}
</n-button>
@@ -392,7 +353,7 @@ onBeforeUnmount(() => {
{{ t('downloadMail') }}
</n-button>
</n-space>
<n-space v-else>
<n-space v-else align="center">
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
@@ -409,12 +370,15 @@ onBeforeUnmount(() => {
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
clearable />
</n-space>
</div>
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; min-height: 50vh; max-height: 100vh;">
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
@@ -439,6 +403,7 @@ onBeforeUnmount(() => {
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>
@@ -473,17 +438,18 @@ onBeforeUnmount(() => {
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
<n-result status="info" :title="count === 0 ? t('emptyInbox') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="InboxRound" :size="100" />
</template>
</n-result>
</n-card>
</template>
</n-split>
</div>
<div class="left" v-else>
<n-space justify="center">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
@@ -496,7 +462,11 @@ onBeforeUnmount(() => {
{{ t('refresh') }}
</n-button>
</n-space>
<div style="overflow: auto; height: 80vh;">
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
<n-input v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" size="small" clearable />
</div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">
@@ -508,11 +478,16 @@ onBeforeUnmount(() => {
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
<n-ellipsis style="max-width: 240px;">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
<n-ellipsis style="max-width: 240px;">
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>

View File

@@ -1,44 +1,16 @@
<script setup>
import { ref } from "vue";
import { useI18n } from 'vue-i18n'
import { useScopedI18n } from '@/i18n/app'
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
import { getDownloadEmlUrl } from '../utils/email-parser';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate } = useGlobalState();
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
const { t } = useI18n({
messages: {
en: {
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
attachments: 'View Attachments',
downloadMail: 'Download Mail',
reply: 'Reply',
forward: 'Forward',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show HTML Mail',
saveToS3: 'Save to S3',
size: 'Size',
fullscreen: 'Fullscreen',
},
zh: {
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
attachments: '查看附件',
downloadMail: '下载邮件',
reply: '回复',
forward: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
size: '大小',
fullscreen: '全屏',
}
}
});
const { t } = useScopedI18n('components.MailContentRenderer')
const props = defineProps({
mail: {
@@ -179,23 +151,26 @@ const handleSaveToS3 = async (filename, blob) => {
</n-button>
</n-space>
<!-- AI 提取信息 -->
<AiExtractInfo :metadata="mail.metadata" />
<!-- 邮件内容 -->
<div class="mail-content">
<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>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
</div>
</div>
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 100vh;">
<n-drawer-content :title="mail.subject" closable>
<div class="fullscreen-mail-content">
<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>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" class="mail-html" />
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
</div>
</n-drawer-content>
</n-drawer>
@@ -255,6 +230,10 @@ const handleSaveToS3 = async (filename, blob) => {
line-height: inherit;
}
.dark-mode .mail-text {
color: #e0e0e0;
}
.mail-iframe {
width: 100%;
height: 100%;
@@ -262,6 +241,10 @@ const handleSaveToS3 = async (filename, blob) => {
min-height: 400px;
}
.dark-mode .mail-iframe {
background-color: #fff;
}
.mail-html {
width: 100%;
height: 100%;

View File

@@ -1,10 +1,11 @@
<script setup>
import { watch, onMounted, ref, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
import { useIsMobile } from '../utils/composables'
import { utcToLocalDate } from '../utils';
import { SendRound } from '@vicons/material'
const message = useMessage()
const isMobile = useIsMobile()
@@ -45,34 +46,7 @@ const multiActionMode = ref(false)
const showMultiActionDelete = ref(false)
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
const { t } = useI18n({
messages: {
en: {
success: 'Success',
refresh: 'Refresh',
showCode: 'Change View Original Code',
pleaseSelectMail: "Please select a mail to view.",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
multiAction: 'Multi Action',
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
},
zh: {
success: '成功',
refresh: '刷新',
showCode: '切换查看元数据',
pleaseSelectMail: "请选择一封邮件查看。",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
}
}
});
const { t } = useScopedI18n('components.SendBox')
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
@@ -239,7 +213,7 @@ onMounted(async () => {
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; height: 80vh;">
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
@@ -297,7 +271,10 @@ onMounted(async () => {
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
<n-result status="info" :title="count === 0 ? t('emptySent') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="SendRound" :size="100" />
</template>
</n-result>
</n-card>
</template>
@@ -312,7 +289,7 @@ onMounted(async () => {
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">

View File

@@ -11,6 +11,10 @@ const props = defineProps({
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
});
const shadowHost = ref(null);
@@ -40,7 +44,13 @@ const renderShadowDom = () => {
// Update content if Shadow DOM exists
if (shadowRoot) {
shadowRoot.innerHTML = props.htmlContent;
const darkModeStyle = props.isDark
? `<style>
:host { color: #e0e0e0; }
a { color: #A8C7FA; }
</style>`
: '';
shadowRoot.innerHTML = darkModeStyle + props.htmlContent;
}
} catch (error) {
console.error('Failed to render Shadow DOM, falling back to v-html:', error);
@@ -68,8 +78,8 @@ onBeforeUnmount(() => {
shadowRoot = null;
});
// Update Shadow DOM when htmlContent changes
watch(() => props.htmlContent, () => {
// Update Shadow DOM when htmlContent or dark mode changes
watch(() => [props.htmlContent, props.isDark], () => {
renderShadowDom();
}, { flush: 'post' });
</script>

View File

@@ -1,33 +1,40 @@
<script setup>
import { ref, watch, onMounted } from "vue";
import { useI18n } from 'vue-i18n'
import { ref, watch } from "vue";
import { useScopedI18n } from '@/i18n/app'
import { useGlobalState } from '../store'
import { getTurnstileLocale } from '../i18n/locale-registry'
import { DEFAULT_LOCALE, isSupportedLocale } from '../i18n/utils'
const { openSettings, isDark } = useGlobalState()
const cfToken = defineModel('value')
const { locale, t } = useI18n({
messages: {
en: {
refresh: 'Refresh'
},
zh: {
refresh: '刷新'
}
}
});
const { locale, t } = useScopedI18n('components.Turnstile')
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
const cfTurnstileId = ref("")
const turnstileLoading = ref(false)
let turnstileRenderQueue = Promise.resolve()
const refresh = () => rerenderTurnstile()
defineExpose({ refresh })
const rerenderTurnstile = () => {
cfToken.value = "";
turnstileRenderQueue = turnstileRenderQueue
.catch(() => { })
.then(() => checkCfTurnstile(true))
turnstileRenderQueue.catch(() => { })
return turnstileRenderQueue
}
const checkCfTurnstile = async (remove) => {
if (!openSettings.value.cfTurnstileSiteKey) return;
turnstileLoading.value = true;
try {
let container = document.getElementById("cf-turnstile");
let container = document.getElementById(containerId);
let count = 100;
while (!container && count-- > 0) {
container = document.getElementById("cf-turnstile");
container = document.getElementById(containerId);
await new Promise(r => setTimeout(r, 10));
}
count = 100;
@@ -37,11 +44,15 @@ const checkCfTurnstile = async (remove) => {
if (remove && cfTurnstileId.value) {
window.turnstile.remove(cfTurnstileId.value);
}
// Cloudflare documents sitekey/theme/language as render-time options and
// exposes remove()/render() for widget lifecycle updates, so recreate the
// widget when any of those inputs change:
// https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/
cfTurnstileId.value = window.turnstile.render(
"#cf-turnstile",
`#${containerId}`,
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
language: getTurnstileLocale(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE),
theme: isDark.value ? 'dark' : 'light',
callback: function (token) {
cfToken.value = token;
@@ -53,14 +64,7 @@ const checkCfTurnstile = async (remove) => {
}
}
watch(isDark, async (isDark) => {
checkCfTurnstile(true)
}, { immediate: true })
onMounted(() => {
cfToken.value = "";
checkCfTurnstile(true);
})
watch([isDark, locale, () => openSettings.value.cfTurnstileSiteKey], rerenderTurnstile, { immediate: true })
</script>
<template>
@@ -68,8 +72,8 @@ onMounted(() => {
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<n-flex vertical>
<div id="cf-turnstile"></div>
<n-button text @click="checkCfTurnstile(true)">
<div :id="containerId"></div>
<n-button text @click="rerenderTurnstile">
{{ t('refresh') }}
</n-button>
</n-flex>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { onMounted, ref, h } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import type { DropdownOption } from 'naive-ui'
const props = defineProps({
fetchData: {
@@ -23,32 +24,7 @@ const props = defineProps({
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
test: 'Test',
save: 'Save',
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
enable: 'Enable',
messagePusherDemo: 'Fill with Message Pusher Demo',
messagePusherDoc: 'Message Pusher Doc',
fillInDemoTip: 'Please modify the URL and other settings to your own',
},
zh: {
successTip: '成功',
test: '测试',
save: '保存',
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
enable: '启用',
messagePusherDemo: '填入MessagePusher示例',
messagePusherDoc: 'MessagePusher文档',
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
}
}
});
const { t } = useScopedI18n('components.WebhookComponent')
class WebhookSettings {
enabled: boolean = false
@@ -58,26 +34,131 @@ class WebhookSettings {
body: string = JSON.stringify({}, null, 2)
}
const messagePusherDocLink = "https://github.com/songquanpeng/message-pusher";
interface WebhookPreset {
name: string
doc: string
settings: WebhookSettings
}
const messagePusherDemo = {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
} as WebhookSettings;
const presets: WebhookPreset[] = [
{
name: 'Message Pusher',
doc: 'https://github.com/songquanpeng/message-pusher',
settings: {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
},
},
{
name: 'Bark',
doc: 'https://github.com/Finb/Bark',
settings: {
enabled: true,
url: 'https://api.day.app/YOUR_KEY',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"title": "${subject}",
"body": "From: ${from}\nTo: ${to}\n\n${parsedText}",
"group": "email"
}, null, 2),
},
},
{
name: 'ntfy',
doc: 'https://docs.ntfy.sh/publish/',
settings: {
enabled: true,
url: 'https://ntfy.sh/YOUR_TOPIC',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"topic": "YOUR_TOPIC",
"title": "${subject}",
"message": "From: ${from}\nTo: ${to}\n\n${parsedText}",
"tags": ["envelope"]
}, null, 2),
},
},
{
name: 'Telegram Bot',
doc: 'https://core.telegram.org/bots/api#sendmessage',
settings: {
enabled: true,
url: 'https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"chat_id": "YOUR_CHAT_ID",
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}, null, 2),
},
},
{
name: 'WeChat Work',
doc: 'https://developer.work.weixin.qq.com/document/path/91770',
settings: {
enabled: true,
url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"msgtype": "text",
"text": {
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
}, null, 2),
},
},
{
name: 'Discord',
doc: 'https://discord.com/developers/docs/resources/webhook',
settings: {
enabled: true,
url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}, null, 2),
},
},
]
const fillMessagePuhserDemo = () => {
Object.assign(webhookSettings.value, messagePusherDemo)
const presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({
label: preset.name,
key: index,
}))
const handlePresetSelect = (key: number) => {
const preset = presets[key]
if (!preset) {
message.error('Invalid preset')
return
}
Object.assign(webhookSettings.value, preset.settings)
message.success(t('fillInDemoTip'))
window.open(preset.doc, '_blank', 'noopener,noreferrer')
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
@@ -128,12 +209,11 @@ onMounted(async () => {
<div class="center">
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-button tag="a" :href="messagePusherDocLink" target="_blank" secondary>
{{ t('messagePusherDoc') }}
</n-button>
<n-button @click="fillMessagePuhserDemo" secondary>
{{ t('messagePusherDemo') }}
</n-button>
<n-dropdown :options="presetDropdownOptions" @select="handlePresetSelect">
<n-button secondary>
{{ t('presets') }}
</n-button>
</n-dropdown>
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
{{ t('test') }}
</n-button>

View File

@@ -1,15 +0,0 @@
import { createI18n } from 'vue-i18n'
const i18n = createI18n({
legacy: false, // you must set `false`, to use Composition API
locale: 'zh', // set locale
fallbackLocale: 'en', // set fallback locale
'en': {
messages: {}
},
'zh': {
messages: {}
}
})
export default i18n;

12
frontend/src/i18n/app.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useI18n } from 'vue-i18n'
const withNamespace = (namespace: string, key: string) => `${namespace}.${key}`
export const useScopedI18n = (namespace: string) => {
const composer = useI18n({ useScope: 'global' })
return {
...composer,
t: ((key: string, ...args: unknown[]) => composer.t(withNamespace(namespace, key), ...(args as []))) as typeof composer.t,
}
}

View File

@@ -0,0 +1,18 @@
import { createI18n } from 'vue-i18n'
import {
FALLBACK_LOCALE,
getInitialLocale,
} from './utils'
import { I18N_MESSAGES } from './messages'
const i18n = createI18n({
legacy: false, // you must set `false`, to use Composition API
locale: getInitialLocale(),
fallbackLocale: FALLBACK_LOCALE,
messages: I18N_MESSAGES,
missingWarn: false,
fallbackWarn: false,
})
export default i18n;

View File

@@ -0,0 +1,107 @@
import {
dateDeDE,
dateEnUS,
dateEsAR,
dateJaJP,
datePtBR,
dateZhCN,
deDE,
enUS,
esAR,
jaJP,
ptBR,
zhCN,
} from 'naive-ui'
import type { NDateLocale, NLocale } from 'naive-ui'
type NaiveLocaleConfig = {
locale: NLocale
dateLocale: NDateLocale
}
type LocaleRegistryEntry = {
locale: string
label: string
browserMatches: string[]
naive: NaiveLocaleConfig
turnstileLocale: string
}
export const LOCALE_REGISTRY = [
{
locale: 'zh',
label: '中文',
browserMatches: ['zh'],
naive: { locale: zhCN, dateLocale: dateZhCN },
turnstileLocale: 'zh-CN',
},
{
locale: 'en',
label: 'English',
browserMatches: ['en'],
naive: { locale: enUS, dateLocale: dateEnUS },
turnstileLocale: 'en',
},
{
locale: 'es',
label: 'Español',
browserMatches: ['es'],
naive: { locale: esAR, dateLocale: dateEsAR },
turnstileLocale: 'es',
},
{
locale: 'pt-BR',
label: 'Português (Brasil)',
browserMatches: ['pt'],
naive: { locale: ptBR, dateLocale: datePtBR },
turnstileLocale: 'pt-BR',
},
{
locale: 'ja',
label: '日本語',
browserMatches: ['ja'],
naive: { locale: jaJP, dateLocale: dateJaJP },
turnstileLocale: 'ja',
},
{
locale: 'de',
label: 'Deutsch',
browserMatches: ['de'],
naive: { locale: deDE, dateLocale: dateDeDE },
turnstileLocale: 'de',
},
] as const satisfies readonly LocaleRegistryEntry[]
export type SupportedLocale = (typeof LOCALE_REGISTRY)[number]['locale']
export const SUPPORTED_LOCALES = LOCALE_REGISTRY.map(({ locale }) => locale) as SupportedLocale[]
const localeRegistryMap = Object.fromEntries(
LOCALE_REGISTRY.map((entry) => [entry.locale, entry]),
) as Record<SupportedLocale, (typeof LOCALE_REGISTRY)[number]>
export const getLocaleRegistryEntry = (locale: SupportedLocale) => {
return localeRegistryMap[locale]
}
export const getLocaleLabel = (locale: SupportedLocale) => {
return getLocaleRegistryEntry(locale).label
}
export const getLocaleOptions = () => {
return LOCALE_REGISTRY.map(({ locale, label }) => ({
label,
value: locale,
key: locale,
}))
}
export const getNaiveLocaleConfig = (locale: SupportedLocale) => {
return getLocaleRegistryEntry(locale).naive
}
export const getTurnstileLocale = (locale: SupportedLocale) => {
return getLocaleRegistryEntry(locale).turnstileLocale
}

View File

@@ -0,0 +1,617 @@
export const deMessages = {
"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",
"views.Index.about": "Über",
"views.Admin.about": "Über",
"views.Header.accessHeader": "Zugangspasswort",
"views.Admin.account": "Konto",
"views.Index.accountSettings": "Kontoeinstellungen",
"views.Admin.account_settings": "Kontoeinstellungen",
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
"views.index.Attachment.action": "Aktion",
"views.admin.SenderAccess.action": "Aktion",
"views.user.UserSettings.actions": "Aktionen",
"views.user.AddressManagement.actions": "Aktionen",
"views.index.TelegramAddress.actions": "Aktionen",
"views.admin.Account.actions": "Aktionen",
"views.index.LocalAddress.actions": "Aktionen",
"views.admin.UserManagement.actions": "Aktionen",
"views.admin.AccountSettings.actions": "Aktionen",
"views.admin.AccountSettings.add": "Hinzufügen",
"views.admin.Maintenance.customSqlTip": "Füge benutzerdefinierte DELETE-SQL-Anweisungen für die geplante Bereinigung hinzu. Pro Eintrag ist nur eine DELETE-Anweisung erlaubt.",
"views.admin.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
"components.AddressSelect.address": "Adresse",
"views.user.AddressManagement.address": "Adresse",
"views.index.TelegramAddress.address": "Adresse",
"views.admin.SenderAccess.address": "Adresse",
"views.index.LocalAddress.address": "Adresse",
"views.admin.CreateAccount.address": "Adresse",
"views.admin.AiExtractSettings.allowList": "Adressfreigabeliste (Adresse eingeben und Enter drücken; Wildcards werden unterstützt)",
"views.admin.AccountSettings.send_address_block_list": "Blockierbegriffe für ausgehende E-Mails",
"views.admin.AccountSettings.address_block_list": "Blockierbegriffe für Benutzer (Administrator kann überspringen)",
"views.index.SimpleIndex.addressCopied": "Adresse erfolgreich kopiert",
"views.admin.Statistics.addressCount": "Adressanzahl",
"views.admin.UserManagement.address_count": "Adressanzahl",
"views.User.address_management": "Adressverwaltung",
"views.admin.UserManagement.userAddressManagement": "Adressverwaltung",
"views.index.AddressBar.addressPassword": "Adresspasswort",
"views.admin.CreateAccount.addressPassword": "Adresspasswort",
"views.Admin.adminAccount": "Administrator",
"views.Admin.accessHeader": "Admin-Passwort",
"views.Admin.loginViaPassword": "Administrator mit Passwort anmelden",
"views.admin.AiExtractSettings.title": "KI-E-Mail-Extraktionseinstellungen",
"views.Admin.aiExtractSettings": "KI-Extraktionseinstellungen",
"views.admin.AccountSettings.match_all": "Alle",
"views.admin.AccountSettings.create_address_subdomain_match": "Suffix-Abgleich für Subdomains beim Erstellen von Adressen erlauben",
"views.admin.UserSettings.enableUserRegister": "Benutzerregistrierung erlauben",
"views.admin.AccountSettings.match_any": "Beliebig",
"views.Index.appearance": "Darstellung",
"views.Admin.appearance": "Darstellung",
"views.admin.IpBlacklistSettings.tip_scope": "Gilt für: Adresse erstellen, E-Mail senden, externe Sende-API, Benutzerregistrierung und Code-Prüfung",
"views.index.AccountSettings.clearInboxConfirm": "Möchtest du wirklich alle E-Mails im Posteingang löschen?",
"views.index.AccountSettings.clearSentItemsConfirm": "Möchtest du wirklich alle E-Mails in den gesendeten Elementen löschen?",
"views.admin.Account.multiClearInboxTip": "Möchtest du wirklich den Posteingang der ausgewählten Adressen leeren?",
"views.admin.Account.clearInboxTip": "Möchtest du wirklich den Posteingang dieser E-Mail-Adresse leeren?",
"views.admin.Account.multiClearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente der ausgewählten Adressen leeren?",
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?",
"views.admin.Account.multiDeleteTip": "Möchtest du wirklich die ausgewählten Adressen löschen?",
"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?",
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
"views.admin.AccountSettings.delete_rule_confirm": "Möchtest du diese Regel wirklich löschen?",
"views.admin.UserManagement.deleteUserTip": "Möchtest du diesen Benutzer wirklich löschen?",
"views.Admin.logoutConfirmContent": "Möchtest du dich wirklich aus dem Admin-Bereich abmelden?",
"views.user.UserSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN-Organisationssperrliste",
"views.admin.IpBlacklistSettings.tip_asn": "ASN-Organisation: Nach ISP/Provider blockieren. Groß-/Kleinschreibung-unabhängiger Textabgleich oder Regex.",
"components.AiExtractInfo.authLink": "Authentifizierungslink",
"views.admin.Maintenance.autoCleanup": "Automatische Bereinigung",
"components.MailBox.autoRefresh": "Automatische Aktualisierung",
"views.common.Appearance.autoRefreshInterval": "Automatisches Aktualisierungsintervall (s)",
"views.Index.auto_reply": "Automatische Antwort",
"views.index.AutoReply.autoReply": "Automatische Antwort",
"views.common.Login.autoGeneratedName": "Automatisch erzeugter Name",
"views.admin.SenderAccess.balance": "Guthaben",
"views.admin.Maintenance.basicCleanup": "Grundbereinigung",
"views.user.AddressManagement.unbindAddressTip": "Wechsle vor dem Trennen zu dieser E-Mail-Adresse und speichere die Zugangsdaten.",
"views.index.TelegramAddress.bind": "Verknüpfen",
"views.index.TelegramAddress.bindAddressSuccess": "Adresse erfolgreich verknüpft",
"views.index.LocalAddress.bindAddressSuccess": "Adresse erfolgreich verknüpft",
"views.User.bind_address": "Mailadresse verknüpfen",
"views.admin.IpBlacklistSettings.enable_tip": "IPs blockieren, die Sperrlistenmustern entsprechen und auf limitierte APIs zugreifen",
"views.admin.AccountSettings.fromBlockList": "Blockierbegriffe für eingehende E-Mails",
"views.admin.AccountSettings.block_receive_unknow_address_email": "Empfang von E-Mails an unbekannte Adressen blockieren",
"views.common.Appearance.bottom": "unten",
"views.admin.IpBlacklistSettings.fingerprint_blacklist": "Browser-Fingerprint-Sperrliste",
"views.admin.IpBlacklistSettings.tip_fingerprint": "Browser-Fingerprint: Sperrt per Fingerprint. Unterstützt exakte Treffer oder Regex.",
"views.admin.AccountSettings.cancel": "Abbrechen",
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
"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.admin.UserManagement.changeRole": "Ändern Rolle",
"components.SendBox.showCode": "Ansicht des Originalcodes umschalten",
"views.admin.Telegram.status": "Status prüfen",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env_note": "Wenn du „Umgebungsvariable folgen“ auswählst, wird die Admin-Überschreibung entfernt und der Zustand auf „nicht gesetzt“ zurückgesetzt. Das tatsächliche Ergebnis wird weiterhin durch die Worker-Umgebung und die Prioritätsregeln bestimmt.",
"views.admin.Maintenance.cleanupNow": "Jetzt bereinigen",
"views.admin.Maintenance.cleanupSuccess": "Bereinigung erfolgreich",
"views.admin.Maintenance.addressCreateLabel": "Adressen löschen, die vor mehr als n Tagen erstellt wurden",
"views.admin.Maintenance.emptyAddressLabel": "Leere Adressen löschen, die älter als n Tage sind",
"views.admin.Maintenance.inactiveAddressLabel": "Inaktive Adressen löschen, die älter als n Tage sind",
"views.admin.Maintenance.mailBoxLabel": "Posteingänge löschen, die älter als n Tage sind",
"views.admin.Maintenance.sendBoxLabel": "Postausgänge löschen, die älter als n Tage sind",
"views.admin.Maintenance.unboundAddressLabel": "Nicht verknüpfte Adressen löschen, die älter als n Tage sind",
"views.admin.Maintenance.mailUnknowLabel": "E-Mails mit unbekanntem Empfänger löschen, die älter als n Tage sind",
"views.index.AccountSettings.clearInbox": "Posteingang leeren",
"views.admin.Account.clearInbox": "Posteingang leeren",
"views.index.AccountSettings.clearSentItems": "Gesendete Elemente leeren",
"views.admin.Account.clearSentItems": "Gesendete Elemente leeren",
"views.Header.title": "Cloudflare Temporäre E-Mail",
"views.admin.DatabaseManager.code_db_version": "Vom Code benötigte DB-Version",
"views.user.UserOauth2Callback.codeNotFound": "Code nicht gefunden",
"views.admin.AccountSettings.config": "Konfiguration",
"views.admin.RoleAddressConfig.roleConfigDesc": "Lege die maximale Anzahl von Adressen pro Benutzerrolle fest. Rollenbasierte Limits haben Vorrang vor globalen Einstellungen. Verwende 0 für unbegrenzt.",
"views.Admin.confirm": "Bestätigen",
"views.Admin.logoutConfirmTitle": "Abmeldung bestätigen",
"views.index.AccountSettings.confirmPassword": "Passwort bestätigen",
"views.index.SendMail.content": "Inhalt",
"views.admin.SendMail.content": "Inhalt",
"views.index.SendMail.contentEmpty": "Der Inhalt ist leer",
"views.admin.SendMail.contentEmpty": "Der Inhalt ist leer",
"components.AddressSelect.copied": "Kopiert",
"components.AiExtractInfo.copySuccess": "Erfolgreich kopiert",
"components.AddressSelect.copy": "Kopieren",
"views.index.SimpleIndex.copyAddress": "Kopieren",
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
"views.Footer.copyright": "Urheberrecht",
"views.Admin.account_create": "Konto erstellen",
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
"views.user.UserSettings.createPasskey": "Passkey erstellen",
"views.admin.UserManagement.createUser": "Benutzer erstellen",
"views.user.UserSettings.created_at": "Erstellt am",
"views.admin.Account.created_at": "Erstellt am",
"views.admin.SenderAccess.created_at": "Erstellt am",
"views.admin.UserManagement.created_at": "Erstellt am",
"views.common.Login.credentialLogin": "Mit Zugangsdaten anmelden",
"views.admin.DatabaseManager.current_db_version": "Aktuelle DB-Version",
"views.user.UserBar.currentUser": "Aktuell angemeldeter Benutzer",
"views.admin.UserManagement.roleDonotExist": "Die aktuelle Rolle existiert nicht",
"views.admin.Maintenance.customSqlCleanup": "Benutzerdefinierte SQL-Bereinigung",
"views.admin.AccountSettings.send_mail_daily_limit": "Tageslimit",
"views.admin.AccountSettings.send_mail_daily_limit_invalid": "Das Tageslimit muss eine ganze Zahl größer oder gleich -1 sein",
"views.admin.IpBlacklistSettings.tip_daily_limit": "Tageslimit: Begrenzt die maximale Anzahl von Anfragen pro IP-Adresse und Tag (1-1000000).",
"views.admin.IpBlacklistSettings.daily_request_limit": "Tägliches Anfrage-Limit",
"views.Header.dark": "Dunkel",
"views.Admin.database": "Datenbank",
"views.admin.DatabaseManager.need_initialization_tip": "Die Datenbank muss initialisiert werden. Bitte zuerst initialisieren.",
"views.admin.DatabaseManager.initializationSuccess": "Datenbank erfolgreich initialisiert",
"views.admin.DatabaseManager.migrationSuccess": "Datenbank erfolgreich migriert",
"views.admin.DatabaseManager.need_migration_tip": "Die Datenbank muss migriert werden. Bitte zuerst migrieren.",
"components.MailBox.delete": "Löschen",
"components.MailContentRenderer.delete": "Löschen",
"components.SendBox.delete": "Löschen",
"views.index.Attachment.delete": "Löschen",
"views.admin.Account.delete": "Löschen",
"views.admin.UserOauth2Settings.delete": "Löschen",
"views.admin.SenderAccess.delete": "Löschen",
"views.admin.UserManagement.delete": "Löschen",
"views.admin.AccountSettings.delete_rule": "Löschen",
"views.admin.Maintenance.deleteCustomSql": "Löschen",
"views.index.AccountSettings.deleteAccount": "Konto löschen",
"views.admin.Account.deleteAccount": "Konto löschen",
"views.user.UserSettings.deletePasskey": "Passkey löschen",
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
"views.index.Attachment.deleteSuccess": "Erfolgreich gelöscht",
"views.admin.SenderAccess.disable": "Deaktivieren",
"views.Admin.loginViaDisabledCheck": "Passwortprüfung deaktiviert",
"views.common.Appearance.preferShowTextMail": "Text-Mail standardmäßig anzeigen",
"views.admin.AccountSettings.domain_list": "Domain-Liste (optional)",
"views.admin.AccountSettings.source_patterns_tip": "Die Domain-Liste filtert nach Empfängeradresse, die Quell-Regex nach Absenderadresse. Für die Weiterleitung müssen beide Bedingungen erfüllt sein (UND-Logik). Leer lassen, um den jeweiligen Filter zu überspringen.",
"views.admin.UserManagement.domains": "Domains",
"views.index.Attachment.download": "Herunterladen",
"components.MailBox.downloadMail": "Herunterladen Mail",
"components.MailContentRenderer.downloadMail": "Herunterladen Mail",
"views.admin.Maintenance.sqlNamePlaceholder": "z. B. Alte Logs bereinigen",
"views.admin.Maintenance.sqlPlaceholder": "z. B. DELETE FROM raw_mails WHERE source GLOB '*{'@'}example.com' AND created_at < datetime('now', '-3 day')",
"views.admin.AccountSettings.source_patterns_placeholder": "z. B. gmail.com",
"views.admin.AccountSettings.forwarding_rule_warning": "Jede Regel wird unabhängig ausgeführt. Die Weiterleitungsadresse muss verifiziert sein.",
"views.index.SendMail.edit": "Bearbeiten",
"views.admin.SendMail.edit": "Bearbeiten",
"views.user.UserLogin.email": "E-Mail",
"views.admin.UserManagement.email": "E-Mail",
"views.common.Login.email": "E-Mail",
"views.common.Login.credential": "E-Mail-Adresszugangsdaten",
"views.common.Login.emailPasswordRequired": "E-Mail und Passwort sind erforderlich",
"views.admin.UserSettings.emailCheckRegex": "Regex zur E-Mail-Prüfung (z. B. ^[^.]+{'@'}.+$, um Punkte vor {'@'} zu verbieten)",
"views.admin.AccountSettings.email_forwarding_config": "E-Mail-Weiterleitungskonfiguration",
"views.admin.UserOauth2Settings.userEmailFormat": "E-Mail-Regex-Muster",
"views.Admin.mails": "E-Mails",
"views.index.AutoReply.sourcePrefixPlaceholder": "Leer=alle, Präfixabgleich oder /regex/",
"components.WebhookComponent.enable": "Aktivieren",
"views.admin.UserOauth2Settings.enable": "Aktivieren",
"views.admin.SenderAccess.enable": "Aktivieren",
"views.admin.Telegram.enable": "Aktivieren",
"views.admin.UserSettings.enable": "Aktivieren",
"views.admin.AiExtractSettings.enableAllowList": "Aktivieren Adressfreigabeliste",
"views.admin.Webhook.enableAllowList": "Freigabeliste aktivieren (Webhook-Zugriff auf bestimmte Benutzer beschränken)",
"views.index.AutoReply.enableAutoReply": "Automatische Antwort aktivieren",
"views.admin.Maintenance.cronTip": "Um die Cron-Bereinigung zu aktivieren, konfiguriere [crons] im Worker. Siehe Dokumentation; 0 Tage bedeutet alles löschen.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Aktivieren Tägliches Anfrage-Limit",
"views.admin.UserSettings.enableEmailCheckRegex": "E-Mail-Prüfregex aktivieren",
"views.admin.UserOauth2Settings.enableEmailFormat": "E-Mail-Format aktivieren",
"views.admin.Telegram.enableGlobalMailPush": "Globalen Mail-Push aktivieren (Telegram-Chat-ID manuell eingeben)",
"views.admin.IpBlacklistSettings.enable_ip_blacklist": "IP-Sperrliste aktivieren",
"views.admin.IpBlacklistSettings.enable_ip_whitelist": "IP-Freigabeliste aktivieren (streng)",
"views.admin.UserOauth2Settings.enableMailAllowList": "Aktivieren E-Mail-Adressfreigabeliste(Manually enterable)",
"views.admin.UserSettings.enableMailAllowList": "Aktivieren E-Mail-Adressfreigabeliste(Manually enterable)",
"views.admin.UserSettings.enableMailVerify": "Mail-Prüfung aktivieren (die Absenderadresse muss im System vorhanden sein, Guthaben haben und normal senden können)",
"views.admin.Telegram.enableTelegramAllowList": "Aktivieren Telegram-Freigabeliste(Manually input Chat ID)",
"views.admin.IpBlacklistSettings.asn_blacklist_placeholder": "ASN-Organisation eingeben (z. B. Google, Amazon)",
"views.admin.IpBlacklistSettings.fingerprint_blacklist_placeholder": "Fingerprint-ID eingeben (z. B. a1b2c3d4e5f6g7h8)",
"views.admin.IpBlacklistSettings.daily_request_limit_placeholder": "Limit eingeben (z. B. 1000)",
"views.admin.IpBlacklistSettings.ip_blacklist_placeholder": "Muster eingeben (z. B. 192.168.1 oder ^10\\.0\\.0\\.5$)",
"views.common.Login.bindUserAddressError": "Fehler beim Verknüpfen der E-Mail-Adresse mit dem Benutzer",
"views.admin.IpBlacklistSettings.ip_whitelist_placeholder": "Exakte IP (z. B. 1.2.3.4) oder verankerte Regex (z. B. ^192\\.168\\.1\\.\\d+$)",
"views.index.SimpleIndex.exitSimpleIndex": "Einfachen Modus verlassen",
"components.MailBox.keywordQueryTip": "Aktuelle Seite filtern",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env": "Umgebungsvariable folgen",
"views.admin.AccountSettings.create_address_subdomain_match_force_disable": "Erzwingt deaktivieren",
"views.admin.AccountSettings.create_address_subdomain_match_force_enable": "Erzwingt aktivieren",
"views.user.UserLogin.forgotPassword": "Passwort vergessen",
"components.MailBox.forwardMail": "Weiterleiten",
"components.MailContentRenderer.forward": "Weiterleiten",
"views.admin.AccountSettings.forward_address": "Weiterleitungsadresse",
"views.admin.AccountSettings.forward_address_required": "Eine Weiterleitungsadresse ist erforderlich",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Vollbild",
"views.common.Login.generateName": "Zufälligen Namen erzeugen",
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
"views.common.Login.help": "Hilfe",
"views.Header.home": "Startseite",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
"views.admin.CreateAccount.enablePrefix": "Wenn Präfix aktiviert ist",
"views.common.AdminContact.adminContact": "Wenn du Hilfe brauchst, kontaktiere bitte den Administrator ({msg})",
"views.admin.Telegram.init": "Initialisieren",
"views.admin.DatabaseManager.init": "Datenbank initialisieren",
"views.admin.AccountSettings.regex_invalid": "Ungültiges Regex-Muster",
"views.Admin.ipBlacklistSettings": "IP-Sperrliste",
"views.admin.IpBlacklistSettings.ip_blacklist": "Muster der IP-Sperrliste",
"views.admin.IpBlacklistSettings.title": "Einstellungen der IP-Sperrliste",
"views.admin.IpBlacklistSettings.tip_ip": "IP-Sperrliste: Unterstützt Textabgleich (z. B. \"192.168.1\") oder Regex (z. B. \"^10\\.0\\.0\\.5$\").",
"views.admin.IpBlacklistSettings.whitelist_empty_warning": "Die IP-Freigabeliste ist aktiviert, aber leer. Der Server ignoriert dies, um eine Aussperrung zu vermeiden. Bitte vor dem Aktivieren mindestens einen Eintrag hinzufügen.",
"views.admin.IpBlacklistSettings.ip_whitelist": "IP-Freigabeliste Muster",
"views.admin.IpBlacklistSettings.tip_whitelist": "IP-Freigabeliste: Strenger Freigabemodus. Einfache Einträge müssen EXAKT zur IP passen (keine Teilzeichenfolge). Verwende verankerte Regex (^...$) für Bereiche. Freigegebene IPs überspringen die Sperrlistenprüfung.",
"views.admin.SenderAccess.is_enabled": "Aktiviert",
"views.admin.Account.itemCount": "itemCount",
"views.admin.SenderAccess.itemCount": "itemCount",
"views.admin.UserManagement.itemCount": "itemCount",
"views.user.UserMailBox.addressQueryTip": "Leer lassen, um alle Adressen abzufragen",
"views.admin.Account.addressQueryTip": "Leer lassen, um alle Adressen abzufragen",
"views.admin.Mails.addressQueryTip": "Leer lassen, um alle Adressen abzufragen",
"views.common.Appearance.left": "links",
"views.common.Login.getNewEmailTip2": "Wenn es leer bleibt, wird eine zufällige E-Mail-Adresse erzeugt.",
"views.Header.light": "Hell",
"views.admin.IpBlacklistSettings.enable_daily_limit_tip": "Anzahl der API-Anfragen pro IP-Adresse und Tag begrenzen",
"components.AddressSelect.localAddresses": "Lokale Adressen",
"views.common.Login.bindUserInfo": "Angemeldete Benutzer: Anmeldung ohne verknüpfte E-Mail oder das Erstellen einer neuen Adresse verknüpft sie mit dem aktuellen Benutzer",
"views.user.UserOauth2Callback.logging": "Anmeldung läuft...",
"views.user.UserLogin.login": "Anmelden",
"views.common.Login.login": "Anmelden",
"views.common.Login.loginAndBind": "Anmelden und verknüpfen",
"views.Admin.loginMethod": "Anmeldemethode",
"views.user.UserBar.fetchUserSettingsError": "Das Anmeldepasswort ist ungültig oder das Konto existiert nicht. Es könnte auch ein Netzwerkproblem vorliegen. Bitte später erneut versuchen.",
"views.user.UserLogin.loginWith": "Mit {provider} anmelden",
"views.user.UserLogin.loginWithPasskey": "Mit Passkey anmelden",
"views.user.UserSettings.logout": "Abmelden",
"views.user.BindAddress.logout": "Abmelden",
"views.Admin.logout": "Abmelden",
"views.index.AccountSettings.logout": "Abmelden",
"views.Admin.logoutSuccess": "Erfolgreich abgemeldet",
"views.admin.UserOauth2Settings.mailAllowList": "E-Mail-Adressfreigabeliste",
"views.admin.UserSettings.mailAllowList": "E-Mail-Adressfreigabeliste",
"views.index.SimpleIndex.addressCredential": "E-Mail-Adresszugangsdaten",
"views.index.AddressBar.addressCredential": "E-Mail-Adresszugangsdaten",
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
"views.admin.CreateAccount.addressCredential": "E-Mail-Adresszugangsdaten",
"views.index.AddressBar.fetchAddressError": "Die E-Mail-Zugangsdaten sind ungültig oder das Konto existiert nicht. Es kann auch ein Netzwerkproblem sein. Bitte später erneut versuchen.",
"views.Index.mailbox": "Posteingang",
"views.User.user_mail_box_tab": "Posteingang",
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
"views.admin.Account.mail_count": "E-Mail-Anzahl",
"views.admin.Statistics.mailCount": "E-Mail-Anzahl",
"views.admin.UserAddressManagement.mail_count": "E-Mail-Anzahl",
"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.index.SimpleIndex.refreshSuccess": "E-Mails erfolgreich aktualisiert",
"views.Admin.unknow": "E-Mails mit unbekanntem Empfänger",
"views.Admin.maintenance": "Wartung",
"views.index.AddressBar.addressManage": "Verwalten",
"views.admin.AccountSettings.source_match_mode": "Abgleichsmodus",
"views.admin.RoleAddressConfig.maxAddressCount": "Maximale Adressanzahl (0 = unbegrenzt)",
"views.admin.UserSettings.maxAddressCount": "Maximale Anzahl verknüpfbarer E-Mail-Adressen (0 = unbegrenzt)",
"views.Header.menu": "Menü",
"views.admin.DatabaseManager.migration": "Datenbank migrieren",
"views.admin.SenderAccess.modify": "Ändern",
"views.admin.AccountSettings.send_mail_monthly_limit": "Monatslimit",
"views.admin.AccountSettings.send_mail_monthly_limit_invalid": "Das Monatslimit muss eine ganze Zahl größer oder gleich -1 sein",
"components.MailBox.multiAction": "Mehrfachaktion",
"components.SendBox.multiAction": "Mehrfachaktion",
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
"views.admin.Account.multiDelete": "Mehrfach löschen",
"views.user.AddressManagement.name": "Name",
"views.index.AutoReply.name": "Name",
"views.admin.Account.name": "Name",
"views.admin.UserOauth2Settings.name": "Name",
"views.admin.UserAddressManagement.name": "Name",
"views.admin.Maintenance.sqlName": "Name",
"views.index.AccountSettings.newPassword": "Neues Passwort",
"views.admin.Account.newPassword": "Neues Passwort",
"components.MailBox.nextMail": "Weiter",
"views.index.SimpleIndex.nextPage": "Weiter",
"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.",
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
"views.admin.UserOauth2Settings.oauth2Type": "OAuth2-Typ",
"views.Header.ok": "OK",
"views.Admin.ok": "OK",
"views.index.AddressBar.ok": "OK",
"views.admin.SenderAccess.ok": "OK",
"views.common.Login.ok": "OK",
"views.admin.AccountSettings.create_address_subdomain_match_tip": "Wirkt sich nur auf die Domainvalidierung von /api/new_address und /admin/new_address aus. Beispiel: Wenn aktiviert, kann foo.example.com der Basisdomain example.com entsprechen.",
"components.AiExtractInfo.open": "Öffnen",
"views.index.AddressBar.linkWithAddressCredential": "Automatischen E-Mail-Anmeldelink öffnen",
"views.admin.CreateAccount.linkWithAddressCredential": "Automatischen E-Mail-Anmeldelink öffnen",
"views.index.SendMail.options": "Optionen",
"views.admin.SendMail.options": "Optionen",
"components.AiExtractInfo.otherLink": "Weiterer Link",
"views.user.UserSettings.passkeyCreated": "Passkey erfolgreich erstellt",
"views.user.UserSettings.passkey_name": "Passkey-Name",
"views.user.UserLogin.password": "Passwort",
"views.admin.UserManagement.password": "Passwort",
"views.common.Login.password": "Passwort",
"views.index.AccountSettings.passwordChanged": "Passwort erfolgreich geändert",
"views.common.Login.passwordLogin": "Passwortanmeldung",
"views.admin.Account.passwordResetSuccess": "Passwort erfolgreich zurückgesetzt",
"views.index.AccountSettings.passwordMismatch": "Passwörter stimmen nicht überein",
"views.index.SendMail.successSend": "Bitte den Postausgang prüfen. Falls es fehlschlägt, Guthaben prüfen oder später erneut versuchen.",
"views.admin.SendMail.successSend": "Bitte prüfe deinen Postausgang. Falls es fehlgeschlagen ist, versuche es später erneut.",
"views.user.UserLogin.pleaseCompleteTurnstile": "Bitte Turnstile abschließen",
"views.index.SimpleIndex.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie zum Anmelden verwenden.",
"views.index.AddressBar.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie verwenden, um dich bei deinem E-Mail-Konto anzumelden.",
"views.admin.Account.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie verwenden, um dich bei deinem E-Mail-Konto anzumelden.",
"views.admin.CreateAccount.addressCredentialTip": "Bitte kopiere die E-Mail-Adresszugangsdaten. Du kannst sie verwenden, um dich bei deinem E-Mail-Konto anzumelden.",
"views.Admin.accessTip": "Bitte das Admin-Passwort eingeben",
"views.Header.accessTip": "Bitte das korrekte Zugangspasswort eingeben",
"views.admin.AccountSettings.address_block_list_placeholder": "Bitte die zu blockierenden Schlüsselwörter eingeben",
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
"views.user.UserLogin.pleaseInputEmail": "Bitte die E-Mail eingeben",
"views.user.UserLogin.pleaseInput": "Bitte E-Mail und Passwort eingeben",
"views.admin.Maintenance.tip": "Bitte die Tage eingeben",
"views.common.Login.getNewEmailTip1": "Bitte die gewünschte E-Mail-Adresse eingeben. Erlaubt sind nur:",
"views.common.Login.credentialInput": "Bitte die Zugangsdaten der Mailadresse eingeben",
"views.admin.SenderAccess.modalTip": "Bitte das Sendeguthaben eingeben",
"views.user.UserLogin.pleaseLogin": "Bitte anmelden",
"views.common.Login.pleaseGetNewEmail": "Bitte anmelden oder auf „Neue E-Mail erstellen“ klicken, um eine neue Adresse zu erhalten.",
"components.WebhookComponent.fillInDemoTip": "Bitte URL und weitere Einstellungen an deine Umgebung anpassen",
"components.SendBox.pleaseSelectMail": "Bitte eine E-Mail zum Anzeigen auswählen.",
"views.admin.Account.pleaseSelectAddress": "Bitte eine Adresse auswählen",
"components.MailBox.pleaseSelectMail": "Bitte eine E-Mail auswählen",
"views.admin.UserManagement.prefix": "Präfix",
"components.WebhookComponent.presets": "Voreinstellungen",
"views.index.SendMail.preview": "Vorschau",
"views.admin.SendMail.preview": "Vorschau",
"views.admin.UserOauth2Settings.iconPreview": "Vorschau",
"components.MailBox.prevMail": "Zurück",
"views.index.SimpleIndex.prevPage": "Zurück",
"components.MailBox.query": "Abfragen",
"views.user.UserMailBox.query": "Abfragen",
"views.Index.query": "Abfragen",
"views.admin.SendBox.query": "Abfragen",
"views.admin.Account.query": "Abfragen",
"views.admin.SenderAccess.query": "Abfragen",
"views.admin.UserManagement.query": "Abfragen",
"views.admin.Mails.query": "Abfragen",
"views.Admin.qucickSetup": "Schnelleinrichtung",
"views.index.SendMail.toMailEmpty": "Die Empfängeradresse ist leer",
"views.admin.SendMail.toMailEmpty": "Die Empfängeradresse ist leer",
"views.index.SendMail.toName": "Name und Adresse des Empfängers; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
"views.admin.SendMail.toName": "Name und Adresse des Empfängers; Namen leer lassen, um die E-Mail-Adresse zu verwenden",
"components.MailBox.refresh": "Aktualisieren",
"components.Turnstile.refresh": "Aktualisieren",
"components.SendBox.refresh": "Aktualisieren",
"views.index.SimpleIndex.refreshMails": "Aktualisieren",
"components.MailBox.refreshAfter": "Aktualisieren in {msg} Sekunden",
"views.index.SimpleIndex.refreshAfter": "Aktualisieren in {msg} Sekunden",
"views.admin.AccountSettings.regex_too_long": "Regex-Muster zu lang (max. 200 Zeichen)",
"views.user.UserLogin.register": "Registrieren",
"views.user.UserSettings.renamePasskey": "Passkey umbenennen",
"views.admin.UserOauth2Settings.userEmailReplace": "Ersetzungsvorlage",
"components.MailBox.reply": "Antworten",
"components.MailContentRenderer.reply": "Antworten",
"views.index.SendMail.requestAccess": "Zugriff anfordern",
"views.user.UserLogin.resetPassword": "Zurücksetzen Passwort",
"views.admin.Account.resetPassword": "Zurücksetzen Passwort",
"views.admin.UserManagement.resetPassword": "Zurücksetzen Passwort",
"views.index.SendMail.rich text": "Rich Text",
"views.admin.SendMail.rich text": "Rich Text",
"views.common.Appearance.right": "rechts",
"views.admin.UserManagement.role": "Rolle",
"views.admin.RoleAddressConfig.role": "Rolle",
"views.Admin.roleAddressConfig": "Rollenbasierte Adresskonfiguration",
"views.admin.AccountSettings.rule_index": "Regel",
"views.Index.s3Attachment": "S3-Anhang",
"components.WebhookComponent.save": "Speichern",
"views.index.AutoReply.save": "Speichern",
"views.admin.AiExtractSettings.save": "Speichern",
"views.admin.UserOauth2Settings.save": "Speichern",
"views.admin.IpBlacklistSettings.save": "Speichern",
"views.admin.Telegram.save": "Speichern",
"views.admin.UserSettings.save": "Speichern",
"views.admin.RoleAddressConfig.save": "Speichern",
"views.admin.AccountSettings.save": "Speichern",
"views.admin.Maintenance.save": "Speichern",
"views.admin.Webhook.save": "Speichern",
"views.admin.Maintenance.saveSuccess": "Erfolgreich gespeichert",
"views.admin.UserOauth2Settings.successTip": "Erfolgreich gespeichert",
"views.admin.IpBlacklistSettings.successTip": "Erfolgreich gespeichert",
"views.admin.UserSettings.successTip": "Erfolgreich gespeichert",
"views.admin.AccountSettings.successTip": "Erfolgreich gespeichert",
"components.MailBox.saveToS3": "In S3 speichern",
"components.MailContentRenderer.saveToS3": "In S3 speichern",
"views.Index.saveToS3Success": "In S3 gespeichert",
"components.MailBox.selectAll": "Alles auf dieser Seite auswählen",
"components.SendBox.selectAll": "Alles auf dieser Seite auswählen",
"views.admin.Account.selectAll": "Alles auf dieser Seite auswählen",
"views.admin.AccountSettings.select_domain": "Domain auswählen",
"views.admin.Account.selectedItems": "Ausgewählt",
"views.index.SendMail.send": "Senden",
"views.admin.SendMail.send": "Senden",
"views.Index.sendbox": "Postausgang",
"views.Admin.sendBox": "Postausgang",
"views.user.AddressManagement.send_count": "Sendeanzahl",
"views.admin.Account.send_count": "Sendeanzahl",
"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.admin.Statistics.sendMailCount": "Anzahl gesendeter E-Mails",
"views.admin.AccountSettings.send_mail_limit": "Sende-Limit",
"views.user.UserLogin.sendVerificationCode": "Bestätigungscode senden",
"views.Admin.senderAccess": "Sender-Zugriffssteuerung",
"views.admin.SendMail.fromMailEmpty": "Die Absenderadresse ist leer",
"views.index.AutoReply.sourcePrefix": "Absenderfilter",
"components.AiExtractInfo.serviceLink": "Servicelink",
"views.index.AutoReply.settings": "Einstellungen",
"views.index.AccountSettings.showAddressCredential": "Adresszugangsdaten anzeigen",
"components.MailBox.attachments": "Anhänge anzeigen",
"components.MailBox.showHtmlMail": "HTML-Mail anzeigen",
"components.MailContentRenderer.showHtmlMail": "HTML-Mail anzeigen",
"views.admin.Account.showCredential": "E-Mail-Adresszugangsdaten anzeigen",
"views.user.UserSettings.showPasskeyList": "Passkey-Liste anzeigen",
"components.MailBox.showTextMail": "Textmail anzeigen",
"components.MailContentRenderer.showTextMail": "Textmail anzeigen",
"views.Index.enterSimpleMode": "Einfacher Modus",
"components.MailContentRenderer.size": "Größe",
"views.admin.Account.source_meta": "Quelle",
"views.admin.AccountSettings.source_patterns": "Regex für Quelladresse (optional)",
"views.admin.Maintenance.sqlStatement": "SQL-Anweisung (nur DELETE)",
"views.user.UserOauth2Callback.stateNotMatch": "State stimmt nicht überein",
"views.Admin.statistics": "Statistiken",
"views.Header.status": "Status",
"views.admin.IpBlacklistSettings.enable_whitelist_tip": "Strenger Modus: Nur IPs aus der Freigabeliste dürfen auf limitierte APIs zugreifen. Alle anderen IPs werden abgewiesen.",
"views.index.AutoReply.subject": "Betreff",
"views.index.SendMail.subject": "Betreff",
"views.admin.SendMail.subject": "Betreff",
"views.index.SendMail.subjectEmpty": "Der Betreff ist leer",
"views.admin.SendMail.subjectEmpty": "Der Betreff ist leer",
"components.AiExtractInfo.subscriptionLink": "Abonnement-Link",
"views.user.AddressManagement.success": "Erfolg",
"views.index.TelegramAddress.success": "Erfolg",
"views.index.LocalAddress.success": "Erfolg",
"views.admin.UserAddressManagement.success": "Erfolg",
"components.WebhookComponent.successTip": "Erfolg",
"components.MailBox.success": "Erfolg",
"components.SendBox.success": "Erfolg",
"views.index.AccountSettings.success": "Erfolg",
"views.index.AutoReply.success": "Erfolg",
"views.admin.AiExtractSettings.successTip": "Erfolg",
"views.admin.Account.success": "Erfolg",
"views.admin.SenderAccess.success": "Erfolg",
"views.admin.UserManagement.success": "Erfolg",
"views.admin.Telegram.successTip": "Erfolg",
"views.admin.RoleAddressConfig.successTip": "Erfolg",
"views.admin.Webhook.successTip": "Erfolg",
"views.admin.CreateAccount.successTip": "Erfolgreich erstellt",
"views.admin.Telegram.globalMailPushListTip": "Unterstützt chat_id von Privat-/Gruppen-/Kanal-Chats. Sende deinem Bot eine Nachricht und rufe dann diesen Link auf, um die chat_id zu sehen: https://api.telegram.org/bot<Ersetze durch dein BOT TOKEN>/getUpdates",
"views.user.AddressManagement.targetUserEmail": "Zielbenutzer-E-Mail",
"views.admin.Telegram.telegramAllowList": "Telegram-Freigabeliste (Chat-ID manuell eingeben)",
"views.Admin.telegram": "Telegram-Bot",
"views.admin.Telegram.miniAppUrl": "Telegram Mini App URL",
"components.WebhookComponent.test": "Test",
"views.index.SendMail.text": "Text",
"views.admin.SendMail.text": "Text",
"views.user.UserSettings.passordTip": "Der Server erhält nur den Hash des Passworts und niemals das Klartext-Passwort. Daher kann er dein Passwort weder sehen noch wiederherstellen. Wenn der Administrator die E-Mail-Verifizierung aktiviert, kannst du es im Inkognito-Modus zurücksetzen.",
"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.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",
"views.user.AddressManagement.transferAddress": "Adresse übertragen",
"views.user.AddressManagement.transferAddressTip": "Wenn du diese Adresse an einen anderen Benutzer überträgst, wird sie aus deinem Konto entfernt und in dessen Konto verschoben. Möchtest du die Adresse wirklich übertragen?",
"views.common.Appearance.useSideMargin": "Seitliche Abstände links und rechts auf der Seite aktivieren",
"views.admin.AiExtractSettings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
"views.admin.UserOauth2Settings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
"views.admin.Telegram.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
"views.admin.UserSettings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
"views.admin.AccountSettings.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
"views.admin.Webhook.manualInputPrompt": "Eingeben und mit Enter hinzufügen",
"views.admin.IpBlacklistSettings.manualInputPrompt": "Muster eingeben und Enter drücken, um es hinzuzufügen",
"views.user.AddressManagement.unbindAddress": "Adressverknüpfung lösen",
"views.index.TelegramAddress.unbindMailAddress": "Mailadressverknüpfung lösen",
"views.index.LocalAddress.unbindMailAddress": "Mailadresszugangsdaten lösen",
"components.MailBox.unselectAll": "Auswahl aufheben",
"components.SendBox.unselectAll": "Auswahl aufheben",
"views.admin.Account.unselectAll": "Auswahl aufheben",
"views.admin.Account.updated_at": "Aktualisiert am",
"views.user.UserSettings.updated_at": "Aktualisiert am",
"components.WebhookComponent.urlMissing": "URL ist erforderlich",
"views.common.Appearance.useIframeShowMail": "HTML-Mail per iframe anzeigen",
"views.admin.CreateAccount.enableRandomSubdomain": "Zufällige Subdomain verwenden",
"views.common.Login.enableRandomSubdomain": "Zufällige Subdomain verwenden",
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com",
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
"views.Header.user": "Benutzer",
"views.Admin.user": "Benutzer",
"components.AddressSelect.userAddresses": "Benutzeradressen",
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
"views.admin.Statistics.userCount": "Benutzeranzahl",
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
"views.Admin.user_management": "Benutzerverwaltung",
"views.Admin.user_settings": "Benutzereinstellungen",
"views.User.user_settings": "Benutzereinstellungen",
"components.AiExtractInfo.authCode": "Bestätigungscode",
"views.user.UserLogin.verifyCode": "Bestätigungscode",
"views.user.UserLogin.verifyCodeSent": "Bestätigungscode gesendet, läuft ab in {timeout} Sekunden",
"views.admin.AccountSettings.verified_address_list": "Liste verifizierter Adressen (kann E-Mails über die interne cf-API senden)",
"views.admin.UserSettings.verifyMailSender": "Mail-Absender verifizieren",
"components.MailContentRenderer.attachments": "Anhänge anzeigen",
"views.admin.Account.viewMails": "E-Mails anzeigen",
"views.admin.Account.viewSendBox": "Postausgang anzeigen",
"views.user.UserLogin.waitforVerifyCode": "Warten {timeout} Sekunden",
"views.admin.Webhook.webhookAllowList": "Webhook-Freigabeliste (erlaubte Mailadresse eingeben und Enter drücken)",
"views.admin.Webhook.notEnabled": "Webhook ist nicht aktiviert",
"components.WebhookComponent.notEnabled": "Webhook ist für dich nicht aktiviert",
"views.Index.webhookSettings": "Webhook-Einstellungen",
"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.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.",
"views.common.Login.getNewEmailTip3": "Du kannst eine Domain aus der Dropdown-Liste auswählen.",
"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",
"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

@@ -0,0 +1,617 @@
export const esMessages = {
"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",
"views.Index.about": "Acerca de",
"views.Admin.about": "Acerca de",
"views.Header.accessHeader": "Contraseña de acceso",
"views.Admin.account": "Cuenta",
"views.Index.accountSettings": "Configuración de la cuenta",
"views.Admin.account_settings": "Configuración de la cuenta",
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
"views.index.Attachment.action": "Acción",
"views.admin.SenderAccess.action": "Acción",
"views.user.UserSettings.actions": "Acciones",
"views.user.AddressManagement.actions": "Acciones",
"views.index.TelegramAddress.actions": "Acciones",
"views.admin.Account.actions": "Acciones",
"views.index.LocalAddress.actions": "Acciones",
"views.admin.UserManagement.actions": "Acciones",
"views.admin.AccountSettings.actions": "Acciones",
"views.admin.AccountSettings.add": "Añadir",
"views.admin.Maintenance.customSqlTip": "Añade sentencias SQL DELETE personalizadas para la limpieza programada. Solo se permite una sentencia DELETE por entrada.",
"views.admin.Maintenance.addCustomSql": "Añadir SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
"components.AddressSelect.address": "Dirección",
"views.user.AddressManagement.address": "Dirección",
"views.index.TelegramAddress.address": "Dirección",
"views.admin.SenderAccess.address": "Dirección",
"views.index.LocalAddress.address": "Dirección",
"views.admin.CreateAccount.address": "Dirección",
"views.admin.AiExtractSettings.allowList": "Lista blanca de direcciones (introduce una dirección y pulsa Enter; admite comodines)",
"views.admin.AccountSettings.send_address_block_list": "Palabras clave bloqueadas para enviar correo",
"views.admin.AccountSettings.address_block_list": "Palabras clave bloqueadas para usuarios (el administrador puede omitirlo)",
"views.index.SimpleIndex.addressCopied": "Dirección copiada correctamente",
"views.admin.Statistics.addressCount": "Cantidad de direcciones",
"views.admin.UserManagement.address_count": "Cantidad de direcciones",
"views.User.address_management": "Gestión de direcciones",
"views.admin.UserManagement.userAddressManagement": "Gestión de direcciones",
"views.index.AddressBar.addressPassword": "Contraseña de la dirección",
"views.admin.CreateAccount.addressPassword": "Contraseña de la dirección",
"views.Admin.adminAccount": "Administrador",
"views.Admin.accessHeader": "Contraseña de administrador",
"views.Admin.loginViaPassword": "Inicio de sesión de administrador con contraseña",
"views.admin.AiExtractSettings.title": "Configuración de extracción de correo con IA",
"views.Admin.aiExtractSettings": "Configuración de extracción con IA",
"views.admin.AccountSettings.match_all": "Todos",
"views.admin.AccountSettings.create_address_subdomain_match": "Permitir coincidencia por sufijo de subdominio al crear direcciones",
"views.admin.UserSettings.enableUserRegister": "Permitir registro de usuarios",
"views.admin.AccountSettings.match_any": "Cualquiera",
"views.Index.appearance": "Apariencia",
"views.Admin.appearance": "Apariencia",
"views.admin.IpBlacklistSettings.tip_scope": "Se aplica a: crear dirección, enviar correo, API externa de envío, registro de usuario y verificación de código",
"views.index.AccountSettings.clearInboxConfirm": "¿Seguro que quieres vaciar todos los correos de tu bandeja de entrada?",
"views.index.AccountSettings.clearSentItemsConfirm": "¿Seguro que quieres vaciar todos los correos enviados?",
"views.admin.Account.multiClearInboxTip": "¿Seguro que quieres vaciar la bandeja de entrada de las direcciones seleccionadas?",
"views.admin.Account.clearInboxTip": "¿Seguro que quieres vaciar la bandeja de entrada de este correo?",
"views.admin.Account.multiClearSentItemsTip": "¿Seguro que quieres vaciar los enviados de las direcciones seleccionadas?",
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?",
"views.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
"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?",
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
"views.admin.AccountSettings.delete_rule_confirm": "¿Seguro que quieres eliminar esta regla?",
"views.admin.UserManagement.deleteUserTip": "¿Seguro que quieres eliminar este usuario?",
"views.Admin.logoutConfirmContent": "¿Seguro que quieres salir del panel de administración?",
"views.user.UserSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizaciones ASN",
"views.admin.IpBlacklistSettings.tip_asn": "Organización ASN: bloquear por ISP/proveedor. Coincidencia de texto sin distinción de mayúsculas o regex.",
"components.AiExtractInfo.authLink": "Enlace de autenticación",
"views.admin.Maintenance.autoCleanup": "Limpieza automática",
"components.MailBox.autoRefresh": "Actualización automática",
"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",
"views.common.Login.autoGeneratedName": "Nombre generado automáticamente",
"views.admin.SenderAccess.balance": "Saldo",
"views.admin.Maintenance.basicCleanup": "Limpieza básica",
"views.user.AddressManagement.unbindAddressTip": "Antes de desvincular, cambia a esta dirección y guarda la credencial del correo.",
"views.index.TelegramAddress.bind": "Vincular",
"views.index.TelegramAddress.bindAddressSuccess": "Dirección vinculada correctamente",
"views.index.LocalAddress.bindAddressSuccess": "Dirección vinculada correctamente",
"views.User.bind_address": "Vincular dirección de correo",
"views.admin.IpBlacklistSettings.enable_tip": "Bloquear IP que coincidan con la lista negra para las API limitadas",
"views.admin.AccountSettings.fromBlockList": "Palabras bloqueadas para recibir correo",
"views.admin.AccountSettings.block_receive_unknow_address_email": "Bloquear correos para direcciones desconocidas",
"views.common.Appearance.bottom": "abajo",
"views.admin.IpBlacklistSettings.fingerprint_blacklist": "Lista negra de huella del navegador",
"views.admin.IpBlacklistSettings.tip_fingerprint": "Huella del navegador: bloquea por huella. Admite coincidencia exacta o regex.",
"views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
"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.admin.UserManagement.changeRole": "Cambiar Rol",
"components.SendBox.showCode": "Cambiar vista del código original",
"views.admin.Telegram.status": "Ver estado",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env_note": "Elegir \"Seguir variable de entorno\" elimina la anulación del administrador y vuelve al estado sin definir. El resultado efectivo sigue dependiendo de las variables del Worker y de las reglas de prioridad.",
"views.admin.Maintenance.cleanupNow": "Limpiar ahora",
"views.admin.Maintenance.cleanupSuccess": "Limpieza completada",
"views.admin.Maintenance.addressCreateLabel": "Limpiar las direcciones creadas hace más de n días",
"views.admin.Maintenance.emptyAddressLabel": "Limpiar las direcciones vacías de hace más de n días",
"views.admin.Maintenance.inactiveAddressLabel": "Limpiar las direcciones inactivas de hace más de n días",
"views.admin.Maintenance.mailBoxLabel": "Limpiar la bandeja de entrada de hace más de n días",
"views.admin.Maintenance.sendBoxLabel": "Limpiar la bandeja de salida de hace más de n días",
"views.admin.Maintenance.unboundAddressLabel": "Limpiar las direcciones no vinculadas de hace más de n días",
"views.admin.Maintenance.mailUnknowLabel": "Limpiar los correos con destinatario desconocido de hace más de n días",
"views.index.AccountSettings.clearInbox": "Vaciar bandeja de entrada",
"views.admin.Account.clearInbox": "Vaciar bandeja de entrada",
"views.index.AccountSettings.clearSentItems": "Vaciar enviados",
"views.admin.Account.clearSentItems": "Vaciar enviados",
"views.Header.title": "Cloudflare Correo Temporal",
"views.admin.DatabaseManager.code_db_version": "Versión de BD requerida por el código",
"views.user.UserOauth2Callback.codeNotFound": "código no encontrado",
"views.admin.AccountSettings.config": "Configuración",
"views.admin.RoleAddressConfig.roleConfigDesc": "Configura el número máximo de direcciones para cada rol. Los límites por rol tienen prioridad sobre la configuración global. Usa 0 para ilimitado.",
"views.Admin.confirm": "Confirmar",
"views.Admin.logoutConfirmTitle": "Confirmar cierre de sesión",
"views.index.AccountSettings.confirmPassword": "Confirmar contraseña",
"views.index.SendMail.content": "Contenido",
"views.admin.SendMail.content": "Contenido",
"views.index.SendMail.contentEmpty": "El contenido está vacío",
"views.admin.SendMail.contentEmpty": "El contenido está vacío",
"components.AddressSelect.copied": "Copiado",
"components.AiExtractInfo.copySuccess": "Copiado correctamente",
"components.AddressSelect.copy": "Copiar",
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Error al copiar",
"views.Footer.copyright": "Derechos de autor",
"views.Admin.account_create": "Crear cuenta",
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
"views.common.Login.getNewEmail": "Crear nuevo correo",
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
"views.user.UserSettings.createPasskey": "Crear passkey",
"views.admin.UserManagement.createUser": "Crear usuario",
"views.user.UserSettings.created_at": "Creado el",
"views.admin.Account.created_at": "Creado el",
"views.admin.SenderAccess.created_at": "Creado el",
"views.admin.UserManagement.created_at": "Creado el",
"views.common.Login.credentialLogin": "Inicio de sesión con credencial",
"views.admin.DatabaseManager.current_db_version": "Versión actual de la BD",
"views.user.UserBar.currentUser": "Usuario actual",
"views.admin.UserManagement.roleDonotExist": "El rol actual no existe",
"views.admin.Maintenance.customSqlCleanup": "Limpieza SQL personalizada",
"views.admin.AccountSettings.send_mail_daily_limit": "Límite diario",
"views.admin.AccountSettings.send_mail_daily_limit_invalid": "El límite diario debe ser un entero mayor o igual que -1",
"views.admin.IpBlacklistSettings.tip_daily_limit": "Límite diario: restringe el número máximo de solicitudes por IP al día (1-1000000).",
"views.admin.IpBlacklistSettings.daily_request_limit": "Límite diario de solicitudes",
"views.Header.dark": "Oscuro",
"views.Admin.database": "Base de datos",
"views.admin.DatabaseManager.need_initialization_tip": "Es necesario inicializar la base de datos. Inicialízala primero.",
"views.admin.DatabaseManager.initializationSuccess": "Base de datos inicializada correctamente",
"views.admin.DatabaseManager.migrationSuccess": "Base de datos migrada correctamente",
"views.admin.DatabaseManager.need_migration_tip": "Es necesario migrar la base de datos. Realiza la migración primero.",
"components.MailBox.delete": "Eliminar",
"components.MailContentRenderer.delete": "Eliminar",
"components.SendBox.delete": "Eliminar",
"views.index.Attachment.delete": "Eliminar",
"views.admin.Account.delete": "Eliminar",
"views.admin.UserOauth2Settings.delete": "Eliminar",
"views.admin.SenderAccess.delete": "Eliminar",
"views.admin.UserManagement.delete": "Eliminar",
"views.admin.AccountSettings.delete_rule": "Eliminar",
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
"views.admin.Account.deleteAccount": "Eliminar cuenta",
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
"views.index.Attachment.deleteSuccess": "Eliminado correctamente",
"views.admin.SenderAccess.disable": "Deshabilitar",
"views.Admin.loginViaDisabledCheck": "Comprobación de contraseña deshabilitada",
"views.common.Appearance.preferShowTextMail": "Mostrar correo en texto por defecto",
"views.admin.AccountSettings.domain_list": "Lista de dominios (opcional)",
"views.admin.AccountSettings.source_patterns_tip": "La lista de dominios filtra por destinatario y la regex de origen por remitente. Ambas condiciones deben cumplirse para reenviar (lógica AND). Deja alguna vacía para omitirla.",
"views.admin.UserManagement.domains": "Dominios",
"views.index.Attachment.download": "Descargar",
"components.MailBox.downloadMail": "Descargar Correo",
"components.MailContentRenderer.downloadMail": "Descargar Correo",
"views.admin.Maintenance.sqlNamePlaceholder": "p. ej., limpiar registros antiguos",
"views.admin.Maintenance.sqlPlaceholder": "p. ej., DELETE FROM raw_mails WHERE source GLOB '*{'@'}example.com' AND created_at < datetime('now', '-3 day')",
"views.admin.AccountSettings.source_patterns_placeholder": "p. ej., gmail.com",
"views.admin.AccountSettings.forwarding_rule_warning": "Cada regla se ejecuta de forma independiente. La dirección de reenvío debe estar verificada.",
"views.index.SendMail.edit": "Editar",
"views.admin.SendMail.edit": "Editar",
"views.user.UserLogin.email": "Correo electrónico",
"views.admin.UserManagement.email": "Correo electrónico",
"views.common.Login.email": "Correo electrónico",
"views.common.Login.credential": "Credencial de dirección de correo",
"views.common.Login.emailPasswordRequired": "El correo y la contraseña son obligatorios",
"views.admin.UserSettings.emailCheckRegex": "Regex de verificación de correo (p. ej., ^[^.]+{'@'}.+$ para impedir puntos antes de {'@'})",
"views.admin.AccountSettings.email_forwarding_config": "Configuración de reenvío de correo",
"views.admin.UserOauth2Settings.userEmailFormat": "Patrón regex del correo",
"views.Admin.mails": "Correos",
"views.index.AutoReply.sourcePrefixPlaceholder": "Vacío=todos, prefijo o /regex/",
"components.WebhookComponent.enable": "Habilitar",
"views.admin.UserOauth2Settings.enable": "Habilitar",
"views.admin.SenderAccess.enable": "Habilitar",
"views.admin.Telegram.enable": "Habilitar",
"views.admin.UserSettings.enable": "Habilitar",
"views.admin.AiExtractSettings.enableAllowList": "Habilitar Lista blanca de direcciones",
"views.admin.Webhook.enableAllowList": "Habilitar lista de permitidos (restringe el acceso del webhook a usuarios específicos)",
"views.index.AutoReply.enableAutoReply": "Habilitar respuesta automática",
"views.admin.Maintenance.cronTip": "Para activar la limpieza por cron, configura [crons] en el worker. Consulta la documentación; 0 días significa limpiar todo.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Habilitar Límite diario de solicitudes",
"views.admin.UserSettings.enableEmailCheckRegex": "Habilitar regex de verificación de correo",
"views.admin.UserOauth2Settings.enableEmailFormat": "Habilitar formato de correo",
"views.admin.Telegram.enableGlobalMailPush": "Habilitar envío global de correos (introduce manualmente el Chat ID de Telegram)",
"views.admin.IpBlacklistSettings.enable_ip_blacklist": "Habilitar Lista negra de IP",
"views.admin.IpBlacklistSettings.enable_ip_whitelist": "Habilitar Lista blanca de IP (estricto)",
"views.admin.UserOauth2Settings.enableMailAllowList": "Habilitar lista blanca de direcciones de correo (editable manualmente)",
"views.admin.UserSettings.enableMailAllowList": "Habilitar lista blanca de direcciones de correo (editable manualmente)",
"views.admin.UserSettings.enableMailVerify": "Habilitar verificación de correo (la dirección remitente debe existir en el sistema, tener saldo y poder enviar normalmente)",
"views.admin.Telegram.enableTelegramAllowList": "Habilitar lista blanca de Telegram (introduce manualmente el Chat ID)",
"views.admin.IpBlacklistSettings.asn_blacklist_placeholder": "Introduce organización ASN (p. ej., Google, Amazon)",
"views.admin.IpBlacklistSettings.fingerprint_blacklist_placeholder": "Introduce el ID de huella (p. ej., a1b2c3d4e5f6g7h8)",
"views.admin.IpBlacklistSettings.daily_request_limit_placeholder": "Introduce el límite (p. ej., 1000)",
"views.admin.IpBlacklistSettings.ip_blacklist_placeholder": "Introduce el patrón (p. ej., 192.168.1 o ^10\\.0\\.0\\.5$)",
"views.common.Login.bindUserAddressError": "Error al vincular la dirección al usuario",
"views.admin.IpBlacklistSettings.ip_whitelist_placeholder": "IP exacta (p. ej., 1.2.3.4) o regex anclada (p. ej., ^192\\.168\\.1\\.\\d+$)",
"views.index.SimpleIndex.exitSimpleIndex": "Salir del modo simple",
"components.MailBox.keywordQueryTip": "Filtrar página actual",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env": "Seguir variable de entorno",
"views.admin.AccountSettings.create_address_subdomain_match_force_disable": "Forzar deshabilitación",
"views.admin.AccountSettings.create_address_subdomain_match_force_enable": "Forzar habilitación",
"views.user.UserLogin.forgotPassword": "Olvidé mi contraseña",
"components.MailBox.forwardMail": "Reenviar",
"components.MailContentRenderer.forward": "Reenviar",
"views.admin.AccountSettings.forward_address": "Dirección de reenvío",
"views.admin.AccountSettings.forward_address_required": "La dirección de reenvío es obligatoria",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Pantalla completa",
"views.common.Login.generateName": "Generar nombre aleatorio",
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
"views.common.Login.help": "Ayuda",
"views.Header.home": "Inicio",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
"views.admin.CreateAccount.enablePrefix": "Si se habilita el prefijo",
"views.common.AdminContact.adminContact": "Si necesitas ayuda, contacta con el administrador ({msg})",
"views.admin.Telegram.init": "Inicializar",
"views.admin.DatabaseManager.init": "Inicializar base de datos",
"views.admin.AccountSettings.regex_invalid": "Patrón regex no válido",
"views.Admin.ipBlacklistSettings": "Lista negra de IP",
"views.admin.IpBlacklistSettings.ip_blacklist": "Patrones de lista negra de IP",
"views.admin.IpBlacklistSettings.title": "Configuración de lista negra de IP",
"views.admin.IpBlacklistSettings.tip_ip": "Lista negra de IP: admite coincidencia por texto (p. ej., \"192.168.1\") o por regex (p. ej., \"^10\\.0\\.0\\.5$\").",
"views.admin.IpBlacklistSettings.whitelist_empty_warning": "La lista blanca de IP está activada, pero vacía. El servidor la ignorará para evitar bloquear el acceso. Añade al menos una entrada antes de activarla.",
"views.admin.IpBlacklistSettings.ip_whitelist": "Lista blanca de IP Patrones",
"views.admin.IpBlacklistSettings.tip_whitelist": "Lista blanca de IP: modo estricto. Las entradas simples deben coincidir exactamente con la IP (sin subcadenas). Usa regex ancladas (^...$) para rangos. Las IP permitidas omiten la comprobación de la lista negra.",
"views.admin.SenderAccess.is_enabled": "Habilitado",
"views.admin.Account.itemCount": "itemCount",
"views.admin.SenderAccess.itemCount": "itemCount",
"views.admin.UserManagement.itemCount": "itemCount",
"views.user.UserMailBox.addressQueryTip": "Déjalo vacío para consultar todas las direcciones",
"views.admin.Account.addressQueryTip": "Déjalo vacío para consultar todas las direcciones",
"views.admin.Mails.addressQueryTip": "Déjalo vacío para consultar todas las direcciones",
"views.common.Appearance.left": "izquierda",
"views.common.Login.getNewEmailTip2": "Si lo dejas vacío, se generará una dirección aleatoria.",
"views.Header.light": "Claro",
"views.admin.IpBlacklistSettings.enable_daily_limit_tip": "Limitar el número de solicitudes API por IP y día",
"components.AddressSelect.localAddresses": "Direcciones locales",
"views.common.Login.bindUserInfo": "Si ya has iniciado sesión, al entrar sin vincular correo o crear uno nuevo se vinculará al usuario actual",
"views.user.UserOauth2Callback.logging": "Iniciando sesión...",
"views.user.UserLogin.login": "Iniciar sesión",
"views.common.Login.login": "Iniciar sesión",
"views.common.Login.loginAndBind": "Iniciar sesión y vincular",
"views.Admin.loginMethod": "Método de inicio de sesión",
"views.user.UserBar.fetchUserSettingsError": "La contraseña de inicio de sesión es incorrecta o la cuenta no existe; también puede tratarse de un problema de red. Inténtalo de nuevo más tarde.",
"views.user.UserLogin.loginWith": "Iniciar sesión con {provider}",
"views.user.UserLogin.loginWithPasskey": "Iniciar sesión con passkey",
"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.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",
"views.index.SimpleIndex.addressCredential": "Credencial de dirección de correo",
"views.index.AddressBar.addressCredential": "Credencial de dirección de correo",
"views.admin.Account.addressCredential": "Credencial de dirección de correo",
"views.admin.CreateAccount.addressCredential": "Credencial de dirección de correo",
"views.index.AddressBar.fetchAddressError": "La credencial del correo no es válida o la cuenta no existe; también puede ser un problema de red. Inténtalo más tarde.",
"views.Index.mailbox": "Bandeja de entrada",
"views.User.user_mail_box_tab": "Bandeja de entrada",
"views.user.AddressManagement.mail_count": "Cantidad de correos",
"views.admin.Account.mail_count": "Cantidad de correos",
"views.admin.Statistics.mailCount": "Cantidad de correos",
"views.admin.UserAddressManagement.mail_count": "Cantidad de correos",
"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.index.SimpleIndex.refreshSuccess": "Correos actualizados correctamente",
"views.Admin.unknow": "Correos con destinatario desconocido",
"views.Admin.maintenance": "Mantenimiento",
"views.index.AddressBar.addressManage": "Gestionar",
"views.admin.AccountSettings.source_match_mode": "Modo de coincidencia",
"views.admin.RoleAddressConfig.maxAddressCount": "Cantidad máxima de direcciones (0 = ilimitado)",
"views.admin.UserSettings.maxAddressCount": "Número máximo de direcciones que se pueden vincular (0 = ilimitado)",
"views.Header.menu": "Menú",
"views.admin.DatabaseManager.migration": "Migrar base de datos",
"views.admin.SenderAccess.modify": "Modificar",
"views.admin.AccountSettings.send_mail_monthly_limit": "Límite mensual",
"views.admin.AccountSettings.send_mail_monthly_limit_invalid": "El límite mensual debe ser un entero mayor o igual que -1",
"components.MailBox.multiAction": "Acción múltiple",
"components.SendBox.multiAction": "Acción múltiple",
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
"views.admin.Account.multiDelete": "Eliminar en lote",
"views.user.AddressManagement.name": "Nombre",
"views.index.AutoReply.name": "Nombre",
"views.admin.Account.name": "Nombre",
"views.admin.UserOauth2Settings.name": "Nombre",
"views.admin.UserAddressManagement.name": "Nombre",
"views.admin.Maintenance.sqlName": "Nombre",
"views.index.AccountSettings.newPassword": "Nueva contraseña",
"views.admin.Account.newPassword": "Nueva contraseña",
"components.MailBox.nextMail": "Siguiente",
"views.index.SimpleIndex.nextPage": "Siguiente",
"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.",
"components.SendBox.emptySent": "No hay correos enviados",
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
"views.admin.UserOauth2Settings.oauth2Type": "Tipo de OAuth2",
"views.Header.ok": "Aceptar",
"views.Admin.ok": "Aceptar",
"views.index.AddressBar.ok": "Aceptar",
"views.admin.SenderAccess.ok": "Aceptar",
"views.common.Login.ok": "Aceptar",
"views.admin.AccountSettings.create_address_subdomain_match_tip": "Solo afecta la validación de dominio en /api/new_address y /admin/new_address. Ejemplo: activado, foo.example.com puede coincidir con example.com.",
"components.AiExtractInfo.open": "Abrir",
"views.index.AddressBar.linkWithAddressCredential": "Abrir enlace de inicio de sesión automático por correo",
"views.admin.CreateAccount.linkWithAddressCredential": "Abrir enlace de inicio de sesión automático por correo",
"views.index.SendMail.options": "Opciones",
"views.admin.SendMail.options": "Opciones",
"components.AiExtractInfo.otherLink": "Otro enlace",
"views.user.UserSettings.passkeyCreated": "Passkey creada correctamente",
"views.user.UserSettings.passkey_name": "Nombre de la passkey",
"views.user.UserLogin.password": "Contraseña",
"views.admin.UserManagement.password": "Contraseña",
"views.common.Login.password": "Contraseña",
"views.index.AccountSettings.passwordChanged": "Contraseña cambiada correctamente",
"views.common.Login.passwordLogin": "Inicio de sesión con contraseña",
"views.admin.Account.passwordResetSuccess": "Contraseña restablecida correctamente",
"views.index.AccountSettings.passwordMismatch": "Las contraseñas no coinciden",
"views.index.SendMail.successSend": "Revisa la bandeja de salida. Si falla, comprueba tu saldo o inténtalo más tarde.",
"views.admin.SendMail.successSend": "Revisa la bandeja de salida. Si falla, inténtalo de nuevo más tarde.",
"views.user.UserLogin.pleaseCompleteTurnstile": "Completa el turnstile",
"views.index.SimpleIndex.addressCredentialTip": "Copia la credencial de la dirección de correo; podrás usarla para iniciar sesión.",
"views.index.AddressBar.addressCredentialTip": "Copia la credencial de la dirección de correo; podrás usarla para iniciar sesión en tu cuenta de correo.",
"views.admin.Account.addressCredentialTip": "Copia la credencial de la dirección de correo; podrás usarla para iniciar sesión en tu cuenta de correo.",
"views.admin.CreateAccount.addressCredentialTip": "Copia la credencial de la dirección de correo; podrás usarla para iniciar sesión en tu cuenta de correo.",
"views.Admin.accessTip": "Introduce la contraseña de administrador",
"views.Header.accessTip": "Introduce la contraseña de acceso correcta",
"views.admin.AccountSettings.address_block_list_placeholder": "Introduce las palabras clave que quieres bloquear",
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
"views.user.UserLogin.pleaseInputEmail": "Introduce el correo",
"views.user.UserLogin.pleaseInput": "Introduce el correo y la contraseña",
"views.admin.Maintenance.tip": "Introduce los días",
"views.common.Login.getNewEmailTip1": "Introduce el correo que quieres usar. Solo se permite: ",
"views.common.Login.credentialInput": "Introduce la credencial de la dirección de correo",
"views.admin.SenderAccess.modalTip": "Introduce el saldo de envío",
"views.user.UserLogin.pleaseLogin": "Inicia sesión",
"views.common.Login.pleaseGetNewEmail": "Inicia sesión o pulsa \"Crear nuevo correo\" para obtener una nueva dirección.",
"components.WebhookComponent.fillInDemoTip": "Modifica la URL y el resto de ajustes por los tuyos",
"components.SendBox.pleaseSelectMail": "Selecciona un correo para verlo.",
"views.admin.Account.pleaseSelectAddress": "Selecciona una dirección",
"components.MailBox.pleaseSelectMail": "Selecciona un correo",
"views.admin.UserManagement.prefix": "Prefijo",
"components.WebhookComponent.presets": "Plantillas",
"views.index.SendMail.preview": "Vista previa",
"views.admin.SendMail.preview": "Vista previa",
"views.admin.UserOauth2Settings.iconPreview": "Vista previa",
"components.MailBox.prevMail": "Anterior",
"views.index.SimpleIndex.prevPage": "Anterior",
"components.MailBox.query": "Consultar",
"views.user.UserMailBox.query": "Consultar",
"views.Index.query": "Consultar",
"views.admin.SendBox.query": "Consultar",
"views.admin.Account.query": "Consultar",
"views.admin.SenderAccess.query": "Consultar",
"views.admin.UserManagement.query": "Consultar",
"views.admin.Mails.query": "Consultar",
"views.Admin.qucickSetup": "Configuración rápida",
"views.index.SendMail.toMailEmpty": "La dirección del destinatario está vacía",
"views.admin.SendMail.toMailEmpty": "La dirección del destinatario está vacía",
"views.index.SendMail.toName": "Nombre y dirección del destinatario; deja el nombre vacío para usar el correo",
"views.admin.SendMail.toName": "Nombre y dirección del destinatario; deja el nombre vacío para usar el correo",
"components.MailBox.refresh": "Actualizar",
"components.Turnstile.refresh": "Actualizar",
"components.SendBox.refresh": "Actualizar",
"views.index.SimpleIndex.refreshMails": "Actualizar",
"components.MailBox.refreshAfter": "Actualizar en {msg} segundos",
"views.index.SimpleIndex.refreshAfter": "Actualizar en {msg} segundos",
"views.admin.AccountSettings.regex_too_long": "El patrón regex es demasiado largo (máx. 200 caracteres)",
"views.user.UserLogin.register": "Registrarse",
"views.user.UserSettings.renamePasskey": "Renombrar passkey",
"views.admin.UserOauth2Settings.userEmailReplace": "Plantilla de reemplazo",
"components.MailBox.reply": "Responder",
"components.MailContentRenderer.reply": "Responder",
"views.index.SendMail.requestAccess": "Solicitar acceso",
"views.user.UserLogin.resetPassword": "Restablecer Contraseña",
"views.admin.Account.resetPassword": "Restablecer Contraseña",
"views.admin.UserManagement.resetPassword": "Restablecer Contraseña",
"views.index.SendMail.rich text": "Texto enriquecido",
"views.admin.SendMail.rich text": "Texto enriquecido",
"views.common.Appearance.right": "derecha",
"views.admin.UserManagement.role": "Rol",
"views.admin.RoleAddressConfig.role": "Rol",
"views.Admin.roleAddressConfig": "Configuración de direcciones por rol",
"views.admin.AccountSettings.rule_index": "Regla",
"views.Index.s3Attachment": "Adjunto S3",
"components.WebhookComponent.save": "Guardar",
"views.index.AutoReply.save": "Guardar",
"views.admin.AiExtractSettings.save": "Guardar",
"views.admin.UserOauth2Settings.save": "Guardar",
"views.admin.IpBlacklistSettings.save": "Guardar",
"views.admin.Telegram.save": "Guardar",
"views.admin.UserSettings.save": "Guardar",
"views.admin.RoleAddressConfig.save": "Guardar",
"views.admin.AccountSettings.save": "Guardar",
"views.admin.Maintenance.save": "Guardar",
"views.admin.Webhook.save": "Guardar",
"views.admin.Maintenance.saveSuccess": "Guardado correctamente",
"views.admin.UserOauth2Settings.successTip": "Guardado correcto",
"views.admin.IpBlacklistSettings.successTip": "Guardado correcto",
"views.admin.UserSettings.successTip": "Guardado correcto",
"views.admin.AccountSettings.successTip": "Guardado correcto",
"components.MailBox.saveToS3": "Guardar en S3",
"components.MailContentRenderer.saveToS3": "Guardar en S3",
"views.Index.saveToS3Success": "guardado en S3 correctamente",
"components.MailBox.selectAll": "Seleccionar toda esta página",
"components.SendBox.selectAll": "Seleccionar toda esta página",
"views.admin.Account.selectAll": "Seleccionar toda esta página",
"views.admin.AccountSettings.select_domain": "Seleccionar dominio",
"views.admin.Account.selectedItems": "Seleccionado",
"views.index.SendMail.send": "Enviar",
"views.admin.SendMail.send": "Enviar",
"views.Index.sendbox": "Bandeja de salida",
"views.Admin.sendBox": "Bandeja de salida",
"views.user.AddressManagement.send_count": "Cantidad enviada",
"views.admin.Account.send_count": "Cantidad enviada",
"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.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",
"views.Admin.senderAccess": "Control de permisos de envío",
"views.admin.SendMail.fromMailEmpty": "La dirección del remitente está vacía",
"views.index.AutoReply.sourcePrefix": "Filtro de remitente",
"components.AiExtractInfo.serviceLink": "Enlace del servicio",
"views.index.AutoReply.settings": "Configuración",
"views.index.AccountSettings.showAddressCredential": "Mostrar credencial de la dirección",
"components.MailBox.attachments": "Mostrar adjuntos",
"components.MailBox.showHtmlMail": "Mostrar correo HTML",
"components.MailContentRenderer.showHtmlMail": "Mostrar correo HTML",
"views.admin.Account.showCredential": "Mostrar credencial de la dirección de correo",
"views.user.UserSettings.showPasskeyList": "Mostrar lista de passkeys",
"components.MailBox.showTextMail": "Mostrar correo en texto",
"components.MailContentRenderer.showTextMail": "Mostrar correo en texto",
"views.Index.enterSimpleMode": "Modo simple",
"components.MailContentRenderer.size": "Tamaño",
"views.admin.Account.source_meta": "Origen",
"views.admin.AccountSettings.source_patterns": "Regex de dirección de origen (opcional)",
"views.admin.Maintenance.sqlStatement": "Sentencia SQL (solo DELETE)",
"views.user.UserOauth2Callback.stateNotMatch": "el estado no coincide",
"views.Admin.statistics": "Estadísticas",
"views.Header.status": "Estado",
"views.admin.IpBlacklistSettings.enable_whitelist_tip": "Modo estricto: solo las IP que coincidan con la lista blanca podrán acceder a las API con límite. Todas las demás serán rechazadas.",
"views.index.AutoReply.subject": "Asunto",
"views.index.SendMail.subject": "Asunto",
"views.admin.SendMail.subject": "Asunto",
"views.index.SendMail.subjectEmpty": "El asunto está vacío",
"views.admin.SendMail.subjectEmpty": "El asunto está vacío",
"components.AiExtractInfo.subscriptionLink": "Enlace de suscripción",
"views.user.AddressManagement.success": "éxito",
"views.index.TelegramAddress.success": "éxito",
"views.index.LocalAddress.success": "éxito",
"views.admin.UserAddressManagement.success": "éxito",
"components.WebhookComponent.successTip": "Correcto",
"components.MailBox.success": "Correcto",
"components.SendBox.success": "Correcto",
"views.index.AccountSettings.success": "Correcto",
"views.index.AutoReply.success": "Correcto",
"views.admin.AiExtractSettings.successTip": "Correcto",
"views.admin.Account.success": "Correcto",
"views.admin.SenderAccess.success": "Correcto",
"views.admin.UserManagement.success": "Correcto",
"views.admin.Telegram.successTip": "Correcto",
"views.admin.RoleAddressConfig.successTip": "Correcto",
"views.admin.Webhook.successTip": "Correcto",
"views.admin.CreateAccount.successTip": "Creado correctamente",
"views.admin.Telegram.globalMailPushListTip": "Admite chat_id de chat privado/grupo/canal. Envía un mensaje al bot y visita este enlace para ver el chat_id: https://api.telegram.org/bot<Reemplaza con tu BOT TOKEN>/getUpdates",
"views.user.AddressManagement.targetUserEmail": "Correo del usuario objetivo",
"views.admin.Telegram.telegramAllowList": "Lista blanca de Telegram (introduce manualmente el Chat ID)",
"views.Admin.telegram": "Bot de Telegram",
"views.admin.Telegram.miniAppUrl": "URL de la Mini App de Telegram",
"components.WebhookComponent.test": "Prueba",
"views.index.SendMail.text": "Texto",
"views.admin.SendMail.text": "Texto",
"views.user.UserSettings.passordTip": "El servidor solo recibe el hash de la contraseña, no la contraseña en texto claro, por lo que no puede verla ni recuperarla. Si el administrador activa la verificación por correo, puedes restablecerla en modo incógnito.",
"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.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",
"views.user.AddressManagement.transferAddress": "Transferir dirección",
"views.user.AddressManagement.transferAddressTip": "Transferir esta dirección a otro usuario la quitará de tu cuenta y la moverá a la suya. ¿Seguro que quieres transferirla?",
"views.common.Appearance.useSideMargin": "Activar márgenes laterales a la izquierda y a la derecha de la página",
"views.admin.AiExtractSettings.manualInputPrompt": "Escribe y pulsa Enter para añadir",
"views.admin.UserOauth2Settings.manualInputPrompt": "Escribe y pulsa Enter para añadir",
"views.admin.Telegram.manualInputPrompt": "Escribe y pulsa Enter para añadir",
"views.admin.UserSettings.manualInputPrompt": "Escribe y pulsa Enter para añadir",
"views.admin.AccountSettings.manualInputPrompt": "Escribe y pulsa Enter para añadir",
"views.admin.Webhook.manualInputPrompt": "Escribe y pulsa Enter para añadir",
"views.admin.IpBlacklistSettings.manualInputPrompt": "Escribe el patrón y pulsa Enter para añadir",
"views.user.AddressManagement.unbindAddress": "Desvincular dirección",
"views.index.TelegramAddress.unbindMailAddress": "Desvincular dirección de correo",
"views.index.LocalAddress.unbindMailAddress": "Desvincular credencial de dirección de correo",
"components.MailBox.unselectAll": "Deseleccionar todo",
"components.SendBox.unselectAll": "Deseleccionar todo",
"views.admin.Account.unselectAll": "Deseleccionar todo",
"views.admin.Account.updated_at": "Actualizado el",
"views.user.UserSettings.updated_at": "Actualizado el",
"components.WebhookComponent.urlMissing": "La URL es obligatoria",
"views.common.Appearance.useIframeShowMail": "Usar iframe para mostrar correo HTML",
"views.admin.CreateAccount.enableRandomSubdomain": "Usar subdominio aleatorio",
"views.common.Login.enableRandomSubdomain": "Usar subdominio aleatorio",
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com",
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
"views.Header.user": "Usuario",
"views.Admin.user": "Usuario",
"components.AddressSelect.userAddresses": "Direcciones del usuario",
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
"views.admin.Statistics.userCount": "Cantidad de usuarios",
"views.admin.UserManagement.user_email": "Correo del usuario",
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
"views.Admin.user_management": "Gestión de usuarios",
"views.Admin.user_settings": "Configuración de usuario",
"views.User.user_settings": "Configuración de usuario",
"components.AiExtractInfo.authCode": "Código de verificación",
"views.user.UserLogin.verifyCode": "Código de verificación",
"views.user.UserLogin.verifyCodeSent": "Código de verificación enviado, expira en {timeout} segundos",
"views.admin.AccountSettings.verified_address_list": "Lista de direcciones verificadas (puede enviar correo mediante la API interna de cf)",
"views.admin.UserSettings.verifyMailSender": "Verificar remitente de correo",
"components.MailContentRenderer.attachments": "Ver adjuntos",
"views.admin.Account.viewMails": "Ver correos",
"views.admin.Account.viewSendBox": "Ver bandeja de salida",
"views.user.UserLogin.waitforVerifyCode": "Espera {timeout} segundos",
"views.admin.Webhook.webhookAllowList": "Lista de permitidos de webhook (introduce el correo autorizado y pulsa Enter)",
"views.admin.Webhook.notEnabled": "El webhook no está habilitado",
"components.WebhookComponent.notEnabled": "El webhook no está habilitado para ti",
"views.Index.webhookSettings": "Configuración de webhook",
"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.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.",
"views.common.Login.getNewEmailTip3": "Puedes elegir un dominio en la lista desplegable.",
"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",
"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

@@ -0,0 +1,617 @@
export const jaMessages = {
"views.index.SimpleIndex.mailCount": "{current} / {total} 件のメール",
"views.admin.Statistics.activeAddressCount30days": "30日間のアクティブなアドレス数",
"views.admin.Statistics.activeAddressCount7days": "7日間のアクティブなアドレス数",
"views.Index.about": "概要",
"views.Admin.about": "概要",
"views.Header.accessHeader": "アクセス用パスワード",
"views.Admin.account": "アカウント",
"views.Index.accountSettings": "アカウント設定",
"views.Admin.account_settings": "アカウント設定",
"views.index.SimpleIndex.accountSettings": "アカウント設定",
"views.index.Attachment.action": "操作",
"views.admin.SenderAccess.action": "操作",
"views.user.UserSettings.actions": "操作",
"views.user.AddressManagement.actions": "操作",
"views.index.TelegramAddress.actions": "操作",
"views.admin.Account.actions": "操作",
"views.index.LocalAddress.actions": "操作",
"views.admin.UserManagement.actions": "操作",
"views.admin.AccountSettings.actions": "操作",
"views.admin.AccountSettings.add": "追加",
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
"components.AddressSelect.address": "アドレス",
"views.user.AddressManagement.address": "アドレス",
"views.index.TelegramAddress.address": "アドレス",
"views.admin.SenderAccess.address": "アドレス",
"views.index.LocalAddress.address": "アドレス",
"views.admin.CreateAccount.address": "アドレス",
"views.admin.AiExtractSettings.allowList": "アドレス許可リスト (アドレスを入力して Enter。ワイルドカード対応)",
"views.admin.AccountSettings.send_address_block_list": "送信メール用のブロックキーワード",
"views.admin.AccountSettings.address_block_list": "ユーザー向けブロックキーワード (管理者はスキップ可能)",
"views.index.SimpleIndex.addressCopied": "アドレスをコピーしました",
"views.admin.Statistics.addressCount": "アドレス数",
"views.admin.UserManagement.address_count": "アドレス数",
"views.User.address_management": "アドレス管理",
"views.admin.UserManagement.userAddressManagement": "アドレス管理",
"views.index.AddressBar.addressPassword": "アドレスパスワード",
"views.admin.CreateAccount.addressPassword": "アドレスパスワード",
"views.Admin.adminAccount": "管理者",
"views.Admin.accessHeader": "管理者パスワード",
"views.Admin.loginViaPassword": "管理者パスワードでログイン",
"views.admin.AiExtractSettings.title": "AI メール抽出設定",
"views.Admin.aiExtractSettings": "AI抽出設定",
"views.admin.AccountSettings.match_all": "すべて",
"views.admin.AccountSettings.create_address_subdomain_match": "アドレス作成時にサブドメイン接尾辞一致を許可",
"views.admin.UserSettings.enableUserRegister": "ユーザー登録を許可",
"views.admin.AccountSettings.match_any": "いずれか",
"views.Index.appearance": "表示",
"views.Admin.appearance": "表示",
"views.admin.IpBlacklistSettings.tip_scope": "対象: アドレス作成、メール送信、外部送信 API、ユーザー登録、コード検証",
"views.index.AccountSettings.clearInboxConfirm": "受信箱のすべてのメールを削除してもよろしいですか?",
"views.index.AccountSettings.clearSentItemsConfirm": "送信済みメールをすべて削除してもよろしいですか?",
"views.admin.Account.multiClearInboxTip": "選択したアドレスの受信箱を削除してもよろしいですか?",
"views.admin.Account.clearInboxTip": "このメールアドレスの受信箱を削除してもよろしいですか?",
"views.admin.Account.multiClearSentItemsTip": "選択したアドレスの送信済みを削除してもよろしいですか?",
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
"views.admin.AccountSettings.delete_rule_confirm": "このルールを削除してもよろしいですか?",
"views.admin.UserManagement.deleteUserTip": "このユーザーを削除してもよろしいですか?",
"views.Admin.logoutConfirmContent": "管理画面からログアウトしてもよろしいですか?",
"views.user.UserSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
"views.admin.IpBlacklistSettings.asn_blacklist": "ASN組織ブラックリスト",
"views.admin.IpBlacklistSettings.tip_asn": "ASN 組織: ISP/プロバイダ単位でブロックします。大文字小文字を区別しないテキスト一致または正規表現に対応します。",
"components.AiExtractInfo.authLink": "認証リンク",
"views.admin.Maintenance.autoCleanup": "自動クリーンアップ",
"components.MailBox.autoRefresh": "自動更新",
"views.common.Appearance.autoRefreshInterval": "自動更新間隔 (秒)",
"views.Index.auto_reply": "自動返信",
"views.index.AutoReply.autoReply": "自動返信",
"views.common.Login.autoGeneratedName": "自動生成名",
"views.admin.SenderAccess.balance": "残高",
"views.admin.Maintenance.basicCleanup": "基本クリーンアップ",
"views.user.AddressManagement.unbindAddressTip": "解除前にこのアドレスへ切り替え、メール資格情報を保存してください。",
"views.index.TelegramAddress.bind": "紐付け",
"views.index.TelegramAddress.bindAddressSuccess": "アドレスの紐付けに成功しました",
"views.index.LocalAddress.bindAddressSuccess": "アドレスの紐付けに成功しました",
"views.User.bind_address": "メールアドレスを紐付け",
"views.admin.IpBlacklistSettings.enable_tip": "ブラックリストに一致するIPによるレート制限APIへのアクセスをブロック",
"views.admin.AccountSettings.fromBlockList": "受信メールのブロックキーワード",
"views.admin.AccountSettings.block_receive_unknow_address_email": "未知アドレス宛メールの受信をブロック",
"views.common.Appearance.bottom": "下",
"views.admin.IpBlacklistSettings.fingerprint_blacklist": "ブラウザ指紋ブラックリスト",
"views.admin.IpBlacklistSettings.tip_fingerprint": "ブラウザ指紋: 指紋でブロックします。完全一致または正規表現に対応します。",
"views.admin.AccountSettings.cancel": "キャンセル",
"components.MailBox.cancelMultiAction": "複数選択をキャンセル",
"components.SendBox.cancelMultiAction": "複数選択をキャンセル",
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
"views.index.AccountSettings.changePassword": "パスワードを変更",
"views.admin.UserManagement.changeRole": "ロールを変更",
"components.SendBox.showCode": "元のコード表示を切り替え",
"views.admin.Telegram.status": "ステータスを確認",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env_note": "「環境変数に従う」を選ぶと、管理者による上書きが解除され、未設定状態に戻ります。実際の動作は引き続き Worker の環境変数と優先順位ルールで決まります。",
"views.admin.Maintenance.cleanupNow": "今すぐクリーンアップ",
"views.admin.Maintenance.cleanupSuccess": "クリーンアップ成功",
"views.admin.Maintenance.addressCreateLabel": "n 日より前に作成されたアドレスを削除",
"views.admin.Maintenance.emptyAddressLabel": "n 日より前の空アドレスを削除",
"views.admin.Maintenance.inactiveAddressLabel": "n 日より前の非アクティブなアドレスを削除",
"views.admin.Maintenance.mailBoxLabel": "n 日より前の受信箱を削除",
"views.admin.Maintenance.sendBoxLabel": "n 日より前の送信箱を削除",
"views.admin.Maintenance.unboundAddressLabel": "n 日より前の未紐付けアドレスを削除",
"views.admin.Maintenance.mailUnknowLabel": "n 日より前の受信者不明メールを削除",
"views.index.AccountSettings.clearInbox": "受信箱を削除",
"views.admin.Account.clearInbox": "受信箱を削除",
"views.index.AccountSettings.clearSentItems": "送信済みを削除",
"views.admin.Account.clearSentItems": "送信済みを削除",
"views.Header.title": "Cloudflare 一時メール",
"views.admin.DatabaseManager.code_db_version": "コードが必要とするDBバージョン",
"views.user.UserOauth2Callback.codeNotFound": "コードが見つかりません",
"views.admin.AccountSettings.config": "設定",
"views.admin.RoleAddressConfig.roleConfigDesc": "各ユーザーロールごとの最大アドレス数を設定します。ロール別制限は全体設定より優先されます。0 は無制限です。",
"views.Admin.confirm": "確認",
"views.Admin.logoutConfirmTitle": "ログアウトを確認",
"views.index.AccountSettings.confirmPassword": "パスワードを確認",
"views.index.SendMail.content": "内容",
"views.admin.SendMail.content": "内容",
"views.index.SendMail.contentEmpty": "内容が空です",
"views.admin.SendMail.contentEmpty": "内容が空です",
"components.AddressSelect.copied": "コピーしました",
"components.AiExtractInfo.copySuccess": "正常にコピーしました",
"components.AddressSelect.copy": "コピー",
"views.index.SimpleIndex.copyAddress": "コピー",
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
"views.Footer.copyright": "著作権",
"views.Admin.account_create": "アカウントを作成",
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
"views.common.Login.getNewEmail": "新しいメールを作成",
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
"views.user.UserSettings.createPasskey": "パスキーを作成",
"views.admin.UserManagement.createUser": "ユーザーを作成",
"views.user.UserSettings.created_at": "作成日時",
"views.admin.Account.created_at": "作成日時",
"views.admin.SenderAccess.created_at": "作成日時",
"views.admin.UserManagement.created_at": "作成日時",
"views.common.Login.credentialLogin": "資格情報でログイン",
"views.admin.DatabaseManager.current_db_version": "現在のDBバージョン",
"views.user.UserBar.currentUser": "現在のログインユーザー",
"views.admin.UserManagement.roleDonotExist": "現在のロールは存在しません",
"views.admin.Maintenance.customSqlCleanup": "カスタムSQLクリーンアップ",
"views.admin.AccountSettings.send_mail_daily_limit": "日次上限",
"views.admin.AccountSettings.send_mail_daily_limit_invalid": "日次上限は -1 以上の整数である必要があります",
"views.admin.IpBlacklistSettings.tip_daily_limit": "日次上限: IP ごとの 1 日あたり最大リクエスト数を制限します (1-1000000)。",
"views.admin.IpBlacklistSettings.daily_request_limit": "1日のリクエスト上限",
"views.Header.dark": "ダーク",
"views.Admin.database": "データベース",
"views.admin.DatabaseManager.need_initialization_tip": "データベースの初期化が必要です。先に初期化してください。",
"views.admin.DatabaseManager.initializationSuccess": "データベースを初期化しました",
"views.admin.DatabaseManager.migrationSuccess": "データベースを移行しました",
"views.admin.DatabaseManager.need_migration_tip": "データベースの移行が必要です。先に移行してください。",
"components.MailBox.delete": "削除",
"components.MailContentRenderer.delete": "削除",
"components.SendBox.delete": "削除",
"views.index.Attachment.delete": "削除",
"views.admin.Account.delete": "削除",
"views.admin.UserOauth2Settings.delete": "削除",
"views.admin.SenderAccess.delete": "削除",
"views.admin.UserManagement.delete": "削除",
"views.admin.AccountSettings.delete_rule": "削除",
"views.admin.Maintenance.deleteCustomSql": "削除",
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
"views.admin.Account.deleteAccount": "アカウントを削除",
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
"views.admin.AccountSettings.delete_success": "削除しました",
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
"views.index.Attachment.deleteSuccess": "正常に削除しました",
"views.admin.SenderAccess.disable": "無効化",
"views.Admin.loginViaDisabledCheck": "パスワードチェックを無効化",
"views.common.Appearance.preferShowTextMail": "既定でテキストメールを表示",
"views.admin.AccountSettings.domain_list": "ドメイン一覧(任意)",
"views.admin.AccountSettings.source_patterns_tip": "ドメイン一覧は受信先アドレスで、送信元正規表現は送信者アドレスでフィルタします。転送には両方の条件を満たす必要がありますAND。どちらかを空欄にするとその条件は無視されます。",
"views.admin.UserManagement.domains": "ドメイン",
"views.index.Attachment.download": "ダウンロード",
"components.MailBox.downloadMail": "メールをダウンロード",
"components.MailContentRenderer.downloadMail": "メールをダウンロード",
"views.admin.Maintenance.sqlNamePlaceholder": "例: 古いログを削除",
"views.admin.Maintenance.sqlPlaceholder": "例: DELETE FROM raw_mails WHERE source GLOB '*{'@'}example.com' AND created_at < datetime('now', '-3 day')",
"views.admin.AccountSettings.source_patterns_placeholder": "例: gmail.com",
"views.admin.AccountSettings.forwarding_rule_warning": "各ルールは個別に実行されます。転送先アドレスは検証済みである必要があります。",
"views.index.SendMail.edit": "編集",
"views.admin.SendMail.edit": "編集",
"views.user.UserLogin.email": "メール",
"views.admin.UserManagement.email": "メール",
"views.common.Login.email": "メール",
"views.common.Login.credential": "メールアドレス資格情報",
"views.common.Login.emailPasswordRequired": "メールアドレスとパスワードは必須です",
"views.admin.UserSettings.emailCheckRegex": "メール検査用正規表現 (例: ^[^.]+{'@'}.+$ で {'@'} の前のドットを禁止)",
"views.admin.AccountSettings.email_forwarding_config": "メール転送設定",
"views.admin.UserOauth2Settings.userEmailFormat": "メール正規表現パターン",
"views.Admin.mails": "メール",
"views.index.AutoReply.sourcePrefixPlaceholder": "空欄=すべて、前方一致、または /regex/",
"components.WebhookComponent.enable": "有効化",
"views.admin.UserOauth2Settings.enable": "有効化",
"views.admin.SenderAccess.enable": "有効化",
"views.admin.Telegram.enable": "有効化",
"views.admin.UserSettings.enable": "有効化",
"views.admin.AiExtractSettings.enableAllowList": "アドレス許可リストを有効化",
"views.admin.Webhook.enableAllowList": "許可リストを有効化 (Webhook へのアクセスを特定ユーザーに制限)",
"views.index.AutoReply.enableAutoReply": "自動返信を有効化",
"views.admin.Maintenance.cronTip": "cron クリーンアップを有効にするには worker の [crons] を設定してください。詳細はドキュメントを参照してください。0 日はすべて削除を意味します。",
"views.admin.IpBlacklistSettings.enable_daily_limit": "1日のリクエスト上限を有効化",
"views.admin.UserSettings.enableEmailCheckRegex": "メール検査用正規表現を有効化",
"views.admin.UserOauth2Settings.enableEmailFormat": "メール形式チェックを有効化",
"views.admin.Telegram.enableGlobalMailPush": "グローバルメール通知を有効化 (Telegram の Chat ID を手動入力)",
"views.admin.IpBlacklistSettings.enable_ip_blacklist": "IPブラックリストを有効化",
"views.admin.IpBlacklistSettings.enable_ip_whitelist": "IPホワイトリストを有効化厳格",
"views.admin.UserOauth2Settings.enableMailAllowList": "メールアドレス許可リストを有効化 (手動入力可)",
"views.admin.UserSettings.enableMailAllowList": "メールアドレス許可リストを有効化 (手動入力可)",
"views.admin.UserSettings.enableMailVerify": "メール検証を有効化 (送信元アドレスはシステム内に存在し、残高があり、正常に送信できる必要があります)",
"views.admin.Telegram.enableTelegramAllowList": "Telegram 許可リストを有効化 (Chat ID を手動入力)",
"views.admin.IpBlacklistSettings.asn_blacklist_placeholder": "ASN組織を入力してください例: Google, Amazon",
"views.admin.IpBlacklistSettings.fingerprint_blacklist_placeholder": "フィンガープリントIDを入力してください例: a1b2c3d4e5f6g7h8",
"views.admin.IpBlacklistSettings.daily_request_limit_placeholder": "上限を入力してください(例: 1000",
"views.admin.IpBlacklistSettings.ip_blacklist_placeholder": "パターンを入力してください(例: 192.168.1 または ^10\\.0\\.0\\.5$",
"views.common.Login.bindUserAddressError": "メールアドレスをユーザーに紐付ける際にエラーが発生しました",
"views.admin.IpBlacklistSettings.ip_whitelist_placeholder": "完全一致IP例: 1.2.3.4)またはアンカー付き正規表現(例: ^192\\.168\\.1\\.\\d+$",
"views.index.SimpleIndex.exitSimpleIndex": "シンプルモードを終了",
"components.MailBox.keywordQueryTip": "現在のページを絞り込む",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env": "環境変数に従う",
"views.admin.AccountSettings.create_address_subdomain_match_force_disable": "強制的に無効化",
"views.admin.AccountSettings.create_address_subdomain_match_force_enable": "強制的に有効化",
"views.user.UserLogin.forgotPassword": "パスワードを忘れた",
"components.MailBox.forwardMail": "転送",
"components.MailContentRenderer.forward": "転送",
"views.admin.AccountSettings.forward_address": "転送先アドレス",
"views.admin.AccountSettings.forward_address_required": "転送先アドレスは必須です",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "全画面",
"views.common.Login.generateName": "ランダム名を生成",
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
"views.common.Appearance.globalTabplacement": "全体タブ位置",
"views.common.Login.help": "ヘルプ",
"views.Header.home": "ホーム",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
"views.admin.CreateAccount.enablePrefix": "プレフィックスを有効化する場合",
"views.common.AdminContact.adminContact": "支援が必要な場合は管理者 ({msg}) に連絡してください",
"views.admin.Telegram.init": "初期化",
"views.admin.DatabaseManager.init": "データベースを初期化",
"views.admin.AccountSettings.regex_invalid": "無効な正規表現です",
"views.Admin.ipBlacklistSettings": "IPブラックリスト",
"views.admin.IpBlacklistSettings.ip_blacklist": "IPブラックリスト パターン",
"views.admin.IpBlacklistSettings.title": "IPブラックリスト 設定",
"views.admin.IpBlacklistSettings.tip_ip": "IP ブラックリスト: テキスト一致 (例: \"192.168.1\") または正規表現 (例: \"^10\\.0\\.0\\.5$\") に対応します。",
"views.admin.IpBlacklistSettings.whitelist_empty_warning": "IP ホワイトリストは有効ですが一覧が空です。ロックアウト防止のためサーバーはこの設定を無視します。有効化する前に少なくとも 1 件追加してください。",
"views.admin.IpBlacklistSettings.ip_whitelist": "IPホワイトリスト パターン",
"views.admin.IpBlacklistSettings.tip_whitelist": "IP ホワイトリスト: 厳格な許可リストです。通常の項目は完全一致の IP である必要があります (部分一致不可)。範囲指定にはアンカー付き正規表現 (^...$) を使用してください。許可された IP はブラックリスト判定をスキップします。",
"views.admin.SenderAccess.is_enabled": "有効",
"views.admin.Account.itemCount": "itemCount",
"views.admin.SenderAccess.itemCount": "itemCount",
"views.admin.UserManagement.itemCount": "itemCount",
"views.user.UserMailBox.addressQueryTip": "空欄で全アドレスを検索します",
"views.admin.Account.addressQueryTip": "空欄で全アドレスを検索します",
"views.admin.Mails.addressQueryTip": "空欄で全アドレスを検索します",
"views.common.Appearance.left": "左",
"views.common.Login.getNewEmailTip2": "空欄のままにするとランダムなメールアドレスが生成されます。",
"views.Header.light": "ライト",
"views.admin.IpBlacklistSettings.enable_daily_limit_tip": "IP ごとの 1 日あたり API リクエスト数を制限",
"components.AddressSelect.localAddresses": "ローカルアドレス",
"views.common.Login.bindUserInfo": "ログイン済みユーザーは、未紐付けのままログインまたは新規作成すると現在のユーザーに紐付きます",
"views.user.UserOauth2Callback.logging": "ログイン中...",
"views.user.UserLogin.login": "ログイン",
"views.common.Login.login": "ログイン",
"views.common.Login.loginAndBind": "ログインして紐付け",
"views.Admin.loginMethod": "ログイン方法",
"views.user.UserBar.fetchUserSettingsError": "ログイン用パスワードが無効か、アカウントが存在しません。ネットワーク接続の問題の可能性もあるため、後でもう一度お試しください。",
"views.user.UserLogin.loginWith": "{provider}でログイン",
"views.user.UserLogin.loginWithPasskey": "パスキーでログイン",
"views.user.UserSettings.logout": "ログアウト",
"views.user.BindAddress.logout": "ログアウト",
"views.Admin.logout": "ログアウト",
"views.index.AccountSettings.logout": "ログアウト",
"views.Admin.logoutSuccess": "ログアウトしました",
"views.admin.UserOauth2Settings.mailAllowList": "メールアドレス許可リスト",
"views.admin.UserSettings.mailAllowList": "メールアドレス許可リスト",
"views.index.SimpleIndex.addressCredential": "メールアドレス資格情報",
"views.index.AddressBar.addressCredential": "メールアドレス資格情報",
"views.admin.Account.addressCredential": "メールアドレス資格情報",
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
"views.Index.mailbox": "受信箱",
"views.User.user_mail_box_tab": "受信箱",
"views.user.AddressManagement.mail_count": "メール数",
"views.admin.Account.mail_count": "メール数",
"views.admin.Statistics.mailCount": "メール数",
"views.admin.UserAddressManagement.mail_count": "メール数",
"views.index.SimpleIndex.deleteSuccess": "メールを削除しました",
"views.user.UserLogin.cannotForgotPassword": "メール検証または登録が無効のため、パスワードを再設定できません。管理者へ連絡してください。",
"views.Admin.mailWebhook": "メールWebhook",
"views.common.Appearance.mailboxSplitSize": "メールボックス分割サイズ",
"views.index.SimpleIndex.refreshSuccess": "メールを更新しました",
"views.Admin.unknow": "受信者不明のメール",
"views.Admin.maintenance": "メンテナンス",
"views.index.AddressBar.addressManage": "管理",
"views.admin.AccountSettings.source_match_mode": "一致モード",
"views.admin.RoleAddressConfig.maxAddressCount": "最大アドレス数 (0 = 無制限)",
"views.admin.UserSettings.maxAddressCount": "紐付け可能なメールアドレスの最大数0 = 無制限)",
"views.Header.menu": "メニュー",
"views.admin.DatabaseManager.migration": "データベースを移行",
"views.admin.SenderAccess.modify": "変更",
"views.admin.AccountSettings.send_mail_monthly_limit": "月次上限",
"views.admin.AccountSettings.send_mail_monthly_limit_invalid": "月次上限は -1 以上の整数である必要があります",
"components.MailBox.multiAction": "複数操作",
"components.SendBox.multiAction": "複数操作",
"views.admin.Account.multiClearInbox": "受信箱を一括削除",
"views.admin.Account.multiClearSentItems": "送信済みを一括削除",
"views.admin.Account.multiDelete": "一括削除",
"views.user.AddressManagement.name": "名前",
"views.index.AutoReply.name": "名前",
"views.admin.Account.name": "名前",
"views.admin.UserOauth2Settings.name": "名前",
"views.admin.UserAddressManagement.name": "名前",
"views.admin.Maintenance.sqlName": "名前",
"views.index.AccountSettings.newPassword": "新しいパスワード",
"views.admin.Account.newPassword": "新しいパスワード",
"components.MailBox.nextMail": "次へ",
"views.index.SimpleIndex.nextPage": "次へ",
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
"views.index.SimpleIndex.noMails": "メールが見つかりません",
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者へ連絡してください。",
"components.SendBox.emptySent": "送信済みメールはありません",
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
"views.Admin.userOauth2Settings": "OAuth2設定",
"views.admin.UserOauth2Settings.oauth2Type": "OAuth2タイプ",
"views.Header.ok": "OK",
"views.Admin.ok": "OK",
"views.index.AddressBar.ok": "OK",
"views.admin.SenderAccess.ok": "OK",
"views.common.Login.ok": "OK",
"views.admin.AccountSettings.create_address_subdomain_match_tip": "/api/new_address と /admin/new_address のドメイン検証にのみ影響します。例: 有効時は foo.example.com が example.com に一致できます。",
"components.AiExtractInfo.open": "開く",
"views.index.AddressBar.linkWithAddressCredential": "メールの自動ログインリンクを開く",
"views.admin.CreateAccount.linkWithAddressCredential": "メールの自動ログインリンクを開く",
"views.index.SendMail.options": "オプション",
"views.admin.SendMail.options": "オプション",
"components.AiExtractInfo.otherLink": "その他のリンク",
"views.user.UserSettings.passkeyCreated": "Passkey を作成しました",
"views.user.UserSettings.passkey_name": "Passkey 名",
"views.user.UserLogin.password": "パスワード",
"views.admin.UserManagement.password": "パスワード",
"views.common.Login.password": "パスワード",
"views.index.AccountSettings.passwordChanged": "パスワードを変更しました",
"views.common.Login.passwordLogin": "パスワードログイン",
"views.admin.Account.passwordResetSuccess": "パスワードをリセットしました",
"views.index.AccountSettings.passwordMismatch": "パスワードが一致しません",
"views.index.SendMail.successSend": "送信箱を確認してください。失敗した場合は残高を確認するか、後でもう一度お試しください。",
"views.admin.SendMail.successSend": "送信箱を確認してください。失敗した場合は、しばらくしてから再試行してください。",
"views.user.UserLogin.pleaseCompleteTurnstile": "Turnstile を完了してください",
"views.index.SimpleIndex.addressCredentialTip": "メールアドレス資格情報をコピーしてください。この情報でログインできます。",
"views.index.AddressBar.addressCredentialTip": "メールアドレス資格情報をコピーしてください。この情報でメールアカウントにログインできます。",
"views.admin.Account.addressCredentialTip": "メールアドレス資格情報をコピーしてください。この情報でメールアカウントにログインできます。",
"views.admin.CreateAccount.addressCredentialTip": "メールアドレス資格情報をコピーしてください。この情報でメールアカウントにログインできます。",
"views.Admin.accessTip": "管理者パスワードを入力してください",
"views.Header.accessTip": "正しいアクセス用パスワードを入力してください",
"views.admin.AccountSettings.address_block_list_placeholder": "ブロックしたいキーワードを入力してください",
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
"views.user.UserLogin.pleaseInputEmail": "メールを入力してください",
"views.user.UserLogin.pleaseInput": "メールアドレスとパスワードを入力してください",
"views.admin.Maintenance.tip": "日数を入力してください",
"views.common.Login.getNewEmailTip1": "使用したいメールアドレスを入力してください。使用可能: ",
"views.common.Login.credentialInput": "メールアドレス資格情報を入力してください",
"views.admin.SenderAccess.modalTip": "送信残高を入力してください",
"views.user.UserLogin.pleaseLogin": "ログインしてください",
"views.common.Login.pleaseGetNewEmail": "ログインするか、「新しいメールを作成」をクリックして新しいアドレスを取得してください。",
"components.WebhookComponent.fillInDemoTip": "URL とその他設定を自分のものに変更してください",
"components.SendBox.pleaseSelectMail": "表示するメールを選択してください。",
"views.admin.Account.pleaseSelectAddress": "アドレスを選択してください",
"components.MailBox.pleaseSelectMail": "メールを選択してください",
"views.admin.UserManagement.prefix": "プレフィックス",
"components.WebhookComponent.presets": "プリセット",
"views.index.SendMail.preview": "プレビュー",
"views.admin.SendMail.preview": "プレビュー",
"views.admin.UserOauth2Settings.iconPreview": "プレビュー",
"components.MailBox.prevMail": "前へ",
"views.index.SimpleIndex.prevPage": "前へ",
"components.MailBox.query": "検索",
"views.user.UserMailBox.query": "検索",
"views.Index.query": "検索",
"views.admin.SendBox.query": "検索",
"views.admin.Account.query": "検索",
"views.admin.SenderAccess.query": "検索",
"views.admin.UserManagement.query": "検索",
"views.admin.Mails.query": "検索",
"views.Admin.qucickSetup": "クイック設定",
"views.index.SendMail.toMailEmpty": "受信者アドレスが空です",
"views.admin.SendMail.toMailEmpty": "受信者アドレスが空です",
"views.index.SendMail.toName": "受信者名とアドレス。名前を空欄にするとメールアドレスを使用します",
"views.admin.SendMail.toName": "受信者名とアドレス。名前を空欄にするとメールアドレスを使用します",
"components.MailBox.refresh": "更新",
"components.Turnstile.refresh": "更新",
"components.SendBox.refresh": "更新",
"views.index.SimpleIndex.refreshMails": "更新",
"components.MailBox.refreshAfter": "あと {msg} 秒で更新",
"views.index.SimpleIndex.refreshAfter": "あと {msg} 秒で更新",
"views.admin.AccountSettings.regex_too_long": "正規表現が長すぎます(最大 200 文字)",
"views.user.UserLogin.register": "登録",
"views.user.UserSettings.renamePasskey": "Passkeyを名前変更",
"views.admin.UserOauth2Settings.userEmailReplace": "置換テンプレート",
"components.MailBox.reply": "返信",
"components.MailContentRenderer.reply": "返信",
"views.index.SendMail.requestAccess": "アクセスを申請",
"views.user.UserLogin.resetPassword": "パスワードをリセット",
"views.admin.Account.resetPassword": "パスワードをリセット",
"views.admin.UserManagement.resetPassword": "パスワードをリセット",
"views.index.SendMail.rich text": "リッチテキスト",
"views.admin.SendMail.rich text": "リッチテキスト",
"views.common.Appearance.right": "右",
"views.admin.UserManagement.role": "ロール",
"views.admin.RoleAddressConfig.role": "ロール",
"views.Admin.roleAddressConfig": "ロール別アドレス設定",
"views.admin.AccountSettings.rule_index": "ルール",
"views.Index.s3Attachment": "S3添付ファイル",
"components.WebhookComponent.save": "保存",
"views.index.AutoReply.save": "保存",
"views.admin.AiExtractSettings.save": "保存",
"views.admin.UserOauth2Settings.save": "保存",
"views.admin.IpBlacklistSettings.save": "保存",
"views.admin.Telegram.save": "保存",
"views.admin.UserSettings.save": "保存",
"views.admin.RoleAddressConfig.save": "保存",
"views.admin.AccountSettings.save": "保存",
"views.admin.Maintenance.save": "保存",
"views.admin.Webhook.save": "保存",
"views.admin.Maintenance.saveSuccess": "保存しました",
"views.admin.UserOauth2Settings.successTip": "保存しました",
"views.admin.IpBlacklistSettings.successTip": "保存しました",
"views.admin.UserSettings.successTip": "保存しました",
"views.admin.AccountSettings.successTip": "保存しました",
"components.MailBox.saveToS3": "S3 に保存",
"components.MailContentRenderer.saveToS3": "S3 に保存",
"views.Index.saveToS3Success": "S3 への保存に成功しました",
"components.MailBox.selectAll": "このページをすべて選択",
"components.SendBox.selectAll": "このページをすべて選択",
"views.admin.Account.selectAll": "このページをすべて選択",
"views.admin.AccountSettings.select_domain": "ドメインを選択",
"views.admin.Account.selectedItems": "選択済み",
"views.index.SendMail.send": "送信",
"views.admin.SendMail.send": "送信",
"views.Index.sendbox": "送信箱",
"views.Admin.sendBox": "送信箱",
"views.user.AddressManagement.send_count": "送信数",
"views.admin.Account.send_count": "送信数",
"views.admin.UserAddressManagement.send_count": "送信数",
"views.Index.sendmail": "メール送信",
"views.Admin.sendMail": "メール送信",
"views.index.SendMail.send_balance": "残り送信枠",
"views.admin.Statistics.sendMailCount": "送信メール数",
"views.admin.AccountSettings.send_mail_limit": "送信上限",
"views.user.UserLogin.sendVerificationCode": "認証コードを送信",
"views.Admin.senderAccess": "送信権限管理",
"views.admin.SendMail.fromMailEmpty": "送信元アドレスが空です",
"views.index.AutoReply.sourcePrefix": "送信者フィルター",
"components.AiExtractInfo.serviceLink": "サービスリンク",
"views.index.AutoReply.settings": "設定",
"views.index.AccountSettings.showAddressCredential": "アドレス資格情報を表示",
"components.MailBox.attachments": "添付ファイルを表示",
"components.MailBox.showHtmlMail": "HTMLメールを表示",
"components.MailContentRenderer.showHtmlMail": "HTMLメールを表示",
"views.admin.Account.showCredential": "メールアドレス資格情報を表示",
"views.user.UserSettings.showPasskeyList": "Passkey 一覧を表示",
"components.MailBox.showTextMail": "テキストメールを表示",
"components.MailContentRenderer.showTextMail": "テキストメールを表示",
"views.Index.enterSimpleMode": "シンプルモード",
"components.MailContentRenderer.size": "サイズ",
"views.admin.Account.source_meta": "送信元",
"views.admin.AccountSettings.source_patterns": "送信元アドレス正規表現 (任意)",
"views.admin.Maintenance.sqlStatement": "SQL文 (DELETEのみ)",
"views.user.UserOauth2Callback.stateNotMatch": "state が一致しません",
"views.Admin.statistics": "統計",
"views.Header.status": "ステータス",
"views.admin.IpBlacklistSettings.enable_whitelist_tip": "厳格モード: ホワイトリストに一致する IP のみがレート制限対象 API にアクセスできます。それ以外の IP はすべて拒否されます。",
"views.index.AutoReply.subject": "件名",
"views.index.SendMail.subject": "件名",
"views.admin.SendMail.subject": "件名",
"views.index.SendMail.subjectEmpty": "件名が空です",
"views.admin.SendMail.subjectEmpty": "件名が空です",
"components.AiExtractInfo.subscriptionLink": "購読リンク",
"views.user.AddressManagement.success": "成功",
"views.index.TelegramAddress.success": "成功",
"views.index.LocalAddress.success": "成功",
"views.admin.UserAddressManagement.success": "成功",
"components.WebhookComponent.successTip": "成功",
"components.MailBox.success": "成功",
"components.SendBox.success": "成功",
"views.index.AccountSettings.success": "成功",
"views.index.AutoReply.success": "成功",
"views.admin.AiExtractSettings.successTip": "成功",
"views.admin.Account.success": "成功",
"views.admin.SenderAccess.success": "成功",
"views.admin.UserManagement.success": "成功",
"views.admin.Telegram.successTip": "成功",
"views.admin.RoleAddressConfig.successTip": "成功",
"views.admin.Webhook.successTip": "成功",
"views.admin.CreateAccount.successTip": "作成に成功",
"views.admin.Telegram.globalMailPushListTip": "個人/グループ/チャンネルの chat_id をサポートします。ボットへメッセージ送信後、このリンクで chat_id を確認できます: https://api.telegram.org/bot<あなたの BOT TOKEN に置換>/getUpdates",
"views.user.AddressManagement.targetUserEmail": "対象ユーザーのメール",
"views.admin.Telegram.telegramAllowList": "Telegram 許可リスト (Chat ID を手動入力)",
"views.Admin.telegram": "Telegramボット",
"views.admin.Telegram.miniAppUrl": "Telegram Mini App URL",
"components.WebhookComponent.test": "テスト",
"views.index.SendMail.text": "テキスト",
"views.admin.SendMail.text": "テキスト",
"views.user.UserSettings.passordTip": "サーバーはパスワードのハッシュ値のみ受け取り、平文は受け取りません。そのためパスワードを閲覧・復元できません。管理者がメール検証を有効にしている場合はシークレットモードで再設定できます。",
"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.index.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.admin.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
"views.common.Appearance.top": "上",
"views.user.AddressManagement.transferAddress": "アドレスを移行",
"views.user.AddressManagement.transferAddressTip": "別のユーザーへアドレスを移行すると、そのアドレスはあなたのアカウントから削除され、相手のアカウントへ移されます。本当に移行しますか?",
"views.common.Appearance.useSideMargin": "ページ左右の余白を有効にする",
"views.admin.AiExtractSettings.manualInputPrompt": "入力して Enter で追加",
"views.admin.UserOauth2Settings.manualInputPrompt": "入力して Enter で追加",
"views.admin.Telegram.manualInputPrompt": "入力して Enter で追加",
"views.admin.UserSettings.manualInputPrompt": "入力して Enter で追加",
"views.admin.AccountSettings.manualInputPrompt": "入力して Enter で追加",
"views.admin.Webhook.manualInputPrompt": "入力して Enter で追加",
"views.admin.IpBlacklistSettings.manualInputPrompt": "パターンを入力して Enter で追加",
"views.user.AddressManagement.unbindAddress": "アドレスの紐付けを解除",
"views.index.TelegramAddress.unbindMailAddress": "メールアドレスの紐付けを解除",
"views.index.LocalAddress.unbindMailAddress": "メールアドレス資格情報の紐付けを解除",
"components.MailBox.unselectAll": "すべての選択を解除",
"components.SendBox.unselectAll": "すべての選択を解除",
"views.admin.Account.unselectAll": "すべての選択を解除",
"views.admin.Account.updated_at": "更新日時",
"views.user.UserSettings.updated_at": "更新日時",
"components.WebhookComponent.urlMissing": "URL は必須です",
"views.common.Appearance.useIframeShowMail": "iframe で HTML メールを表示",
"views.admin.CreateAccount.enableRandomSubdomain": "ランダムサブドメインを使用",
"views.common.Login.enableRandomSubdomain": "ランダムサブドメインを使用",
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
"views.Header.user": "ユーザー",
"views.Admin.user": "ユーザー",
"components.AddressSelect.userAddresses": "ユーザーのアドレス",
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
"views.admin.Statistics.userCount": "ユーザー数",
"views.admin.UserManagement.user_email": "ユーザーメール",
"views.index.AddressBar.userLogin": "ユーザーログイン",
"views.Admin.user_management": "ユーザー管理",
"views.Admin.user_settings": "ユーザー設定",
"views.User.user_settings": "ユーザー設定",
"components.AiExtractInfo.authCode": "認証コード",
"views.user.UserLogin.verifyCode": "認証コード",
"views.user.UserLogin.verifyCodeSent": "認証コードを送信しました, 有効期限 {timeout} 秒",
"views.admin.AccountSettings.verified_address_list": "検証済みアドレス一覧 (cf internal api でメール送信可能)",
"views.admin.UserSettings.verifyMailSender": "メール送信元を検証",
"components.MailContentRenderer.attachments": "添付ファイルを表示",
"views.admin.Account.viewMails": "メールを表示",
"views.admin.Account.viewSendBox": "送信箱を表示",
"views.user.UserLogin.waitforVerifyCode": "待機 {timeout} 秒",
"views.admin.Webhook.webhookAllowList": "Webhook 許可リスト (利用を許可するメールアドレスを入力して Enter)",
"views.admin.Webhook.notEnabled": "Webhook は有効ではありません",
"components.WebhookComponent.notEnabled": "あなたには Webhook が有効ではありません",
"views.Index.webhookSettings": "Webhook設定",
"views.Admin.webhookSettings": "Webhook設定",
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。サブドメインアドレスは受信専用として推奨されます。",
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。サブドメインアドレスは受信専用として推奨されます。",
"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 を有効化または削除するまで反映されません。",
"views.common.Login.getNewEmailTip3": "ドロップダウン一覧からドメインを選択できます。",
"views.admin.AccountSettings.tip": "以下の複数選択項目は手動入力して Enter で追加できます",
"components.MailBox.emptyInbox": "受信箱は空です",
"views.index.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

@@ -0,0 +1,617 @@
export const ptBRMessages = {
"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",
"views.Index.about": "Sobre",
"views.Admin.about": "Sobre",
"views.Header.accessHeader": "Senha de acesso",
"views.Admin.account": "Conta",
"views.Index.accountSettings": "Configurações da conta",
"views.Admin.account_settings": "Configurações da conta",
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
"views.index.Attachment.action": "Ação",
"views.admin.SenderAccess.action": "Ação",
"views.user.UserSettings.actions": "Ações",
"views.user.AddressManagement.actions": "Ações",
"views.index.TelegramAddress.actions": "Ações",
"views.admin.Account.actions": "Ações",
"views.index.LocalAddress.actions": "Ações",
"views.admin.UserManagement.actions": "Ações",
"views.admin.AccountSettings.actions": "Ações",
"views.admin.AccountSettings.add": "Adicionar",
"views.admin.Maintenance.customSqlTip": "Adicione instruções SQL DELETE personalizadas para a limpeza agendada. Apenas uma instrução DELETE é permitida por item.",
"views.admin.Maintenance.addCustomSql": "Adicionar SQL personalizada",
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
"components.AddressSelect.address": "Endereço",
"views.user.AddressManagement.address": "Endereço",
"views.index.TelegramAddress.address": "Endereço",
"views.admin.SenderAccess.address": "Endereço",
"views.index.LocalAddress.address": "Endereço",
"views.admin.CreateAccount.address": "Endereço",
"views.admin.AiExtractSettings.allowList": "Lista branca de endereços (digite o endereço e pressione Enter; suporta curingas)",
"views.admin.AccountSettings.send_address_block_list": "Palavras-chave bloqueadas para envio de e-mail",
"views.admin.AccountSettings.address_block_list": "Palavras-chave bloqueadas para usuários (o administrador pode ignorar)",
"views.index.SimpleIndex.addressCopied": "Endereço copiado com sucesso",
"views.admin.Statistics.addressCount": "Quantidade de endereços",
"views.admin.UserManagement.address_count": "Quantidade de endereços",
"views.User.address_management": "Gerenciamento de endereços",
"views.admin.UserManagement.userAddressManagement": "Gerenciamento de endereços",
"views.index.AddressBar.addressPassword": "Senha do endereço",
"views.admin.CreateAccount.addressPassword": "Senha do endereço",
"views.Admin.adminAccount": "Administrador",
"views.Admin.accessHeader": "Senha de administrador",
"views.Admin.loginViaPassword": "Entrar como administrador com senha",
"views.admin.AiExtractSettings.title": "Configurações de extração de e-mail por IA",
"views.Admin.aiExtractSettings": "Configurações de extração por IA",
"views.admin.AccountSettings.match_all": "Todos",
"views.admin.AccountSettings.create_address_subdomain_match": "Permitir correspondência por sufixo de subdomínio ao criar endereços",
"views.admin.UserSettings.enableUserRegister": "Permitir cadastro de usuários",
"views.admin.AccountSettings.match_any": "Qualquer",
"views.Index.appearance": "Aparência",
"views.Admin.appearance": "Aparência",
"views.admin.IpBlacklistSettings.tip_scope": "Aplica-se a: criar endereço, enviar e-mail, API externa de envio, cadastro de usuário e verificação de código",
"views.index.AccountSettings.clearInboxConfirm": "Tem certeza de que deseja limpar todos os e-mails da sua caixa de entrada?",
"views.index.AccountSettings.clearSentItemsConfirm": "Tem certeza de que deseja limpar todos os e-mails enviados?",
"views.admin.Account.multiClearInboxTip": "Tem certeza de que deseja limpar a caixa de entrada dos endereços selecionados?",
"views.admin.Account.clearInboxTip": "Tem certeza de que deseja limpar a caixa de entrada deste e-mail?",
"views.admin.Account.multiClearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados dos endereços selecionados?",
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?",
"views.admin.Account.multiDeleteTip": "Tem certeza de que deseja excluir os endereços selecionados?",
"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?",
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
"views.admin.AccountSettings.delete_rule_confirm": "Tem certeza de que deseja excluir esta regra?",
"views.admin.UserManagement.deleteUserTip": "Tem certeza de que deseja excluir este usuário?",
"views.Admin.logoutConfirmContent": "Tem certeza de que deseja sair do painel de administração?",
"views.user.UserSettings.logoutConfirm": "Tem certeza de que deseja sair?",
"views.admin.IpBlacklistSettings.asn_blacklist": "Lista negra de organizações ASN",
"views.admin.IpBlacklistSettings.tip_asn": "Organização ASN: bloquear por ISP/provedor. Correspondência de texto sem diferenciar maiúsculas ou regex.",
"components.AiExtractInfo.authLink": "Link de autenticação",
"views.admin.Maintenance.autoCleanup": "Limpeza automática",
"components.MailBox.autoRefresh": "Atualização automática",
"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",
"views.common.Login.autoGeneratedName": "Nome gerado automaticamente",
"views.admin.SenderAccess.balance": "Saldo",
"views.admin.Maintenance.basicCleanup": "Limpeza básica",
"views.user.AddressManagement.unbindAddressTip": "Antes de desvincular, mude para este endereço e salve a credencial do e-mail.",
"views.index.TelegramAddress.bind": "Vincular",
"views.index.TelegramAddress.bindAddressSuccess": "Endereço vinculado com sucesso",
"views.index.LocalAddress.bindAddressSuccess": "Endereço vinculado com sucesso",
"views.User.bind_address": "Vincular endereço de e-mail",
"views.admin.IpBlacklistSettings.enable_tip": "Bloquear IPs que correspondam à lista negra nas APIs com limite",
"views.admin.AccountSettings.fromBlockList": "Palavras bloqueadas para receber e-mail",
"views.admin.AccountSettings.block_receive_unknow_address_email": "Bloquear recebimento de e-mails para endereços desconhecidos",
"views.common.Appearance.bottom": "base",
"views.admin.IpBlacklistSettings.fingerprint_blacklist": "Lista negra de impressão digital do navegador",
"views.admin.IpBlacklistSettings.tip_fingerprint": "Impressão digital do navegador: bloqueia por impressão. Suporta correspondência exata ou regex.",
"views.admin.AccountSettings.cancel": "Cancelar",
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
"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.admin.UserManagement.changeRole": "Alterar Função",
"components.SendBox.showCode": "Alternar visualização do código original",
"views.admin.Telegram.status": "Ver status",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env_note": "Escolher \"Seguir variável de ambiente\" remove a sobrescrita do administrador e retorna ao estado não definido. O resultado efetivo continua sendo controlado pelas variáveis do Worker e pelas regras de precedência.",
"views.admin.Maintenance.cleanupNow": "Limpar agora",
"views.admin.Maintenance.cleanupSuccess": "Limpeza concluída",
"views.admin.Maintenance.addressCreateLabel": "Limpar os endereços criados há mais de n dias",
"views.admin.Maintenance.emptyAddressLabel": "Limpar os endereços vazios de mais de n dias",
"views.admin.Maintenance.inactiveAddressLabel": "Limpar os endereços inativos de mais de n dias",
"views.admin.Maintenance.mailBoxLabel": "Limpar a caixa de entrada de mais de n dias",
"views.admin.Maintenance.sendBoxLabel": "Limpar a caixa de saída de mais de n dias",
"views.admin.Maintenance.unboundAddressLabel": "Limpar os endereços desvinculados de mais de n dias",
"views.admin.Maintenance.mailUnknowLabel": "Limpar os e-mails com destinatário desconhecido de mais de n dias",
"views.index.AccountSettings.clearInbox": "Limpar caixa de entrada",
"views.admin.Account.clearInbox": "Limpar caixa de entrada",
"views.index.AccountSettings.clearSentItems": "Limpar itens enviados",
"views.admin.Account.clearSentItems": "Limpar itens enviados",
"views.Header.title": "Cloudflare E-mail Temporário",
"views.admin.DatabaseManager.code_db_version": "Versão do banco exigida pelo código",
"views.user.UserOauth2Callback.codeNotFound": "código não encontrado",
"views.admin.AccountSettings.config": "Configuração",
"views.admin.RoleAddressConfig.roleConfigDesc": "Configure a quantidade máxima de endereços para cada função. Os limites por função têm prioridade sobre a configuração global. Use 0 para ilimitado.",
"views.Admin.confirm": "Confirmar",
"views.Admin.logoutConfirmTitle": "Confirmar saída",
"views.index.AccountSettings.confirmPassword": "Confirmar senha",
"views.index.SendMail.content": "Conteúdo",
"views.admin.SendMail.content": "Conteúdo",
"views.index.SendMail.contentEmpty": "O conteúdo está vazio",
"views.admin.SendMail.contentEmpty": "O conteúdo está vazio",
"components.AddressSelect.copied": "Copiado",
"components.AiExtractInfo.copySuccess": "Copiado com sucesso",
"components.AddressSelect.copy": "Copiar",
"views.index.SimpleIndex.copyAddress": "Copiar",
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
"views.Footer.copyright": "Direitos autorais",
"views.Admin.account_create": "Criar conta",
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
"views.common.Login.getNewEmail": "Criar novo e-mail",
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
"views.user.UserSettings.createPasskey": "Criar passkey",
"views.admin.UserManagement.createUser": "Criar usuário",
"views.user.UserSettings.created_at": "Criado em",
"views.admin.Account.created_at": "Criado em",
"views.admin.SenderAccess.created_at": "Criado em",
"views.admin.UserManagement.created_at": "Criado em",
"views.common.Login.credentialLogin": "Login com credencial",
"views.admin.DatabaseManager.current_db_version": "Versão atual do banco",
"views.user.UserBar.currentUser": "Usuário atual",
"views.admin.UserManagement.roleDonotExist": "A função atual não existe",
"views.admin.Maintenance.customSqlCleanup": "Limpeza SQL personalizada",
"views.admin.AccountSettings.send_mail_daily_limit": "Limite diário",
"views.admin.AccountSettings.send_mail_daily_limit_invalid": "O limite diário deve ser um inteiro maior ou igual a -1",
"views.admin.IpBlacklistSettings.tip_daily_limit": "Limite diário: restringe o número máximo de requisições por IP por dia (1-1000000).",
"views.admin.IpBlacklistSettings.daily_request_limit": "Limite diário de solicitações",
"views.Header.dark": "Escuro",
"views.Admin.database": "Banco de dados",
"views.admin.DatabaseManager.need_initialization_tip": "É necessário inicializar o banco de dados. Faça a inicialização primeiro.",
"views.admin.DatabaseManager.initializationSuccess": "Banco de dados inicializado com sucesso",
"views.admin.DatabaseManager.migrationSuccess": "Banco de dados migrado com sucesso",
"views.admin.DatabaseManager.need_migration_tip": "É necessário migrar o banco de dados. Execute a migração primeiro.",
"components.MailBox.delete": "Excluir",
"components.MailContentRenderer.delete": "Excluir",
"components.SendBox.delete": "Excluir",
"views.index.Attachment.delete": "Excluir",
"views.admin.Account.delete": "Excluir",
"views.admin.UserOauth2Settings.delete": "Excluir",
"views.admin.SenderAccess.delete": "Excluir",
"views.admin.UserManagement.delete": "Excluir",
"views.admin.AccountSettings.delete_rule": "Excluir",
"views.admin.Maintenance.deleteCustomSql": "Excluir",
"views.index.AccountSettings.deleteAccount": "Excluir conta",
"views.admin.Account.deleteAccount": "Excluir conta",
"views.user.UserSettings.deletePasskey": "Excluir passkey",
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
"views.admin.UserManagement.deleteUser": "Excluir usuário",
"views.index.Attachment.deleteSuccess": "Excluído com sucesso",
"views.admin.SenderAccess.disable": "Desativar",
"views.Admin.loginViaDisabledCheck": "Verificação de senha desativada",
"views.common.Appearance.preferShowTextMail": "Exibir e-mail em texto por padrão",
"views.admin.AccountSettings.domain_list": "Lista de domínios (opcional)",
"views.admin.AccountSettings.source_patterns_tip": "A lista de domínios filtra pelo destinatário e o regex de origem filtra pelo remetente. Ambas as condições precisam corresponder para encaminhar (lógica AND). Deixe qualquer uma vazia para ignorá-la.",
"views.admin.UserManagement.domains": "Domínios",
"views.index.Attachment.download": "Baixar",
"components.MailBox.downloadMail": "Baixar E-mail",
"components.MailContentRenderer.downloadMail": "Baixar E-mail",
"views.admin.Maintenance.sqlNamePlaceholder": "ex.: limpar logs antigos",
"views.admin.Maintenance.sqlPlaceholder": "ex.: DELETE FROM raw_mails WHERE source GLOB '*{'@'}example.com' AND created_at < datetime('now', '-3 day')",
"views.admin.AccountSettings.source_patterns_placeholder": "ex.: gmail.com",
"views.admin.AccountSettings.forwarding_rule_warning": "Cada regra é executada de forma independente. O endereço de encaminhamento deve ser verificado.",
"views.index.SendMail.edit": "Editar",
"views.admin.SendMail.edit": "Editar",
"views.user.UserLogin.email": "E-mail",
"views.admin.UserManagement.email": "E-mail",
"views.common.Login.email": "E-mail",
"views.common.Login.credential": "Credencial do endereço de e-mail",
"views.common.Login.emailPasswordRequired": "E-mail e senha são obrigatórios",
"views.admin.UserSettings.emailCheckRegex": "Regex de verificação de e-mail (ex.: ^[^.]+{'@'}.+$ para impedir pontos antes de {'@'})",
"views.admin.AccountSettings.email_forwarding_config": "Configuração de encaminhamento de e-mail",
"views.admin.UserOauth2Settings.userEmailFormat": "Padrão regex de e-mail",
"views.Admin.mails": "E-mails",
"views.index.AutoReply.sourcePrefixPlaceholder": "Vazio=todos, prefixo ou /regex/",
"components.WebhookComponent.enable": "Ativar",
"views.admin.UserOauth2Settings.enable": "Ativar",
"views.admin.SenderAccess.enable": "Ativar",
"views.admin.Telegram.enable": "Ativar",
"views.admin.UserSettings.enable": "Ativar",
"views.admin.AiExtractSettings.enableAllowList": "Ativar Lista branca de endereços",
"views.admin.Webhook.enableAllowList": "Ativar lista de permissão (restringir o acesso ao webhook a usuários específicos)",
"views.index.AutoReply.enableAutoReply": "Ativar resposta automática",
"views.admin.Maintenance.cronTip": "Para ativar a limpeza por cron, configure [crons] no worker. Consulte a documentação; 0 dias significa limpar tudo.",
"views.admin.IpBlacklistSettings.enable_daily_limit": "Ativar limite diário de solicitações",
"views.admin.UserSettings.enableEmailCheckRegex": "Ativar regex de verificação de e-mail",
"views.admin.UserOauth2Settings.enableEmailFormat": "Ativar formato de e-mail",
"views.admin.Telegram.enableGlobalMailPush": "Ativar envio global de e-mails (informar manualmente o Chat ID do Telegram)",
"views.admin.IpBlacklistSettings.enable_ip_blacklist": "Ativar Lista negra de IP",
"views.admin.IpBlacklistSettings.enable_ip_whitelist": "Ativar Lista branca de IP (estrito)",
"views.admin.UserOauth2Settings.enableMailAllowList": "Ativar lista branca de endereços de e-mail (editável manualmente)",
"views.admin.UserSettings.enableMailAllowList": "Ativar lista branca de endereços de e-mail (editável manualmente)",
"views.admin.UserSettings.enableMailVerify": "Habilitar verificação de e-mail (o endereço remetente deve existir no sistema, ter saldo e poder enviar normalmente)",
"views.admin.Telegram.enableTelegramAllowList": "Ativar lista branca do Telegram (informar manualmente o Chat ID)",
"views.admin.IpBlacklistSettings.asn_blacklist_placeholder": "Informe a organização ASN (ex.: Google, Amazon)",
"views.admin.IpBlacklistSettings.fingerprint_blacklist_placeholder": "Informe o ID da impressão (ex.: a1b2c3d4e5f6g7h8)",
"views.admin.IpBlacklistSettings.daily_request_limit_placeholder": "Informe o limite (ex.: 1000)",
"views.admin.IpBlacklistSettings.ip_blacklist_placeholder": "Informe o padrão (ex.: 192.168.1 ou ^10\\.0\\.0\\.5$)",
"views.common.Login.bindUserAddressError": "Erro ao vincular o endereço ao usuário",
"views.admin.IpBlacklistSettings.ip_whitelist_placeholder": "IP exato (ex.: 1.2.3.4) ou regex ancorada (ex.: ^192\\.168\\.1\\.\\d+$)",
"views.index.SimpleIndex.exitSimpleIndex": "Sair do modo simples",
"components.MailBox.keywordQueryTip": "Filtrar página atual",
"views.admin.AccountSettings.create_address_subdomain_match_follow_env": "Seguir variável de ambiente",
"views.admin.AccountSettings.create_address_subdomain_match_force_disable": "Forçar desativação",
"views.admin.AccountSettings.create_address_subdomain_match_force_enable": "Forçar ativação",
"views.user.UserLogin.forgotPassword": "Esqueci a senha",
"components.MailBox.forwardMail": "Encaminhar",
"components.MailContentRenderer.forward": "Encaminhar",
"views.admin.AccountSettings.forward_address": "Endereço de encaminhamento",
"views.admin.AccountSettings.forward_address_required": "O endereço de encaminhamento é obrigatório",
"views.admin.AccountSettings.forward_placeholder": "forward@example.com",
"components.MailContentRenderer.fullscreen": "Tela cheia",
"views.common.Login.generateName": "Gerar nome aleatório",
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
"views.common.Appearance.globalTabplacement": "Posição global das abas",
"views.common.Login.help": "Ajuda",
"views.Header.home": "Início",
"views.index.SendMail.html": "HTML",
"views.admin.SendMail.html": "HTML",
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
"views.admin.CreateAccount.enablePrefix": "Se ativar o prefixo",
"views.common.AdminContact.adminContact": "Se precisar de ajuda, entre em contato com o administrador ({msg})",
"views.admin.Telegram.init": "Inicializar",
"views.admin.DatabaseManager.init": "Inicializar banco de dados",
"views.admin.AccountSettings.regex_invalid": "Padrão regex inválido",
"views.Admin.ipBlacklistSettings": "Lista negra de IP",
"views.admin.IpBlacklistSettings.ip_blacklist": "Padrões da lista negra de IP",
"views.admin.IpBlacklistSettings.title": "Configurações da lista negra de IP",
"views.admin.IpBlacklistSettings.tip_ip": "Lista negra de IP: aceita correspondência por texto (ex.: \"192.168.1\") ou regex (ex.: \"^10\\.0\\.0\\.5$\").",
"views.admin.IpBlacklistSettings.whitelist_empty_warning": "A lista branca de IP está ativada, mas está vazia. O servidor irá ignorá-la para evitar bloqueio de acesso. Adicione pelo menos uma entrada antes de ativar.",
"views.admin.IpBlacklistSettings.ip_whitelist": "Lista branca de IP Padrões",
"views.admin.IpBlacklistSettings.tip_whitelist": "Lista branca de IP: modo estrito. Entradas simples devem corresponder exatamente ao IP (sem substring). Use regex ancorada (^...$) para intervalos. IPs permitidos ignoram a verificação da lista negra.",
"views.admin.SenderAccess.is_enabled": "Ativado",
"views.admin.Account.itemCount": "itemCount",
"views.admin.SenderAccess.itemCount": "itemCount",
"views.admin.UserManagement.itemCount": "itemCount",
"views.user.UserMailBox.addressQueryTip": "Deixe em branco para consultar todos os endereços",
"views.admin.Account.addressQueryTip": "Deixe em branco para consultar todos os endereços",
"views.admin.Mails.addressQueryTip": "Deixe em branco para consultar todos os endereços",
"views.common.Appearance.left": "esquerda",
"views.common.Login.getNewEmailTip2": "Deixar em branco gerará um endereço de e-mail aleatório.",
"views.Header.light": "Claro",
"views.admin.IpBlacklistSettings.enable_daily_limit_tip": "Limitar o número de requisições de API por IP por dia",
"components.AddressSelect.localAddresses": "Endereços locais",
"views.common.Login.bindUserInfo": "Usuário logado: entrar sem vincular e-mail ou criar um novo endereço vinculará ao usuário atual",
"views.user.UserOauth2Callback.logging": "Entrando...",
"views.user.UserLogin.login": "Entrar",
"views.common.Login.login": "Entrar",
"views.common.Login.loginAndBind": "Entrar e vincular",
"views.Admin.loginMethod": "Método de login",
"views.user.UserBar.fetchUserSettingsError": "A senha de login é inválida ou a conta não existe; também pode ser um problema de conexão. Tente novamente mais tarde.",
"views.user.UserLogin.loginWith": "Entrar com {provider}",
"views.user.UserLogin.loginWithPasskey": "Entrar com passkey",
"views.user.UserSettings.logout": "Sair",
"views.user.BindAddress.logout": "Sair",
"views.Admin.logout": "Sair",
"views.index.AccountSettings.logout": "Sair",
"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",
"views.index.SimpleIndex.addressCredential": "Credencial do endereço de e-mail",
"views.index.AddressBar.addressCredential": "Credencial do endereço de e-mail",
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
"views.admin.CreateAccount.addressCredential": "Credencial do endereço de e-mail",
"views.index.AddressBar.fetchAddressError": "A credencial do e-mail é inválida ou a conta não existe; também pode ser um problema de rede. Tente novamente mais tarde.",
"views.Index.mailbox": "Caixa de entrada",
"views.User.user_mail_box_tab": "Caixa de entrada",
"views.user.AddressManagement.mail_count": "Quantidade de e-mails",
"views.admin.Account.mail_count": "Quantidade de e-mails",
"views.admin.Statistics.mailCount": "Quantidade de e-mails",
"views.admin.UserAddressManagement.mail_count": "Quantidade de e-mails",
"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.index.SimpleIndex.refreshSuccess": "E-mails atualizados com sucesso",
"views.Admin.unknow": "E-mails com destinatário desconhecido",
"views.Admin.maintenance": "Manutenção",
"views.index.AddressBar.addressManage": "Gerenciar",
"views.admin.AccountSettings.source_match_mode": "Modo de correspondência",
"views.admin.RoleAddressConfig.maxAddressCount": "Quantidade máxima de endereços (0 = ilimitado)",
"views.admin.UserSettings.maxAddressCount": "Número máximo de endereços que podem ser vinculados (0 = ilimitado)",
"views.Header.menu": "Menu",
"views.admin.DatabaseManager.migration": "Migrar banco de dados",
"views.admin.SenderAccess.modify": "Modificar",
"views.admin.AccountSettings.send_mail_monthly_limit": "Limite mensal",
"views.admin.AccountSettings.send_mail_monthly_limit_invalid": "O limite mensal deve ser um inteiro maior ou igual a -1",
"components.MailBox.multiAction": "Ação múltipla",
"components.SendBox.multiAction": "Ação múltipla",
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
"views.admin.Account.multiDelete": "Excluir em lote",
"views.user.AddressManagement.name": "Nome",
"views.index.AutoReply.name": "Nome",
"views.admin.Account.name": "Nome",
"views.admin.UserOauth2Settings.name": "Nome",
"views.admin.UserAddressManagement.name": "Nome",
"views.admin.Maintenance.sqlName": "Nome",
"views.index.AccountSettings.newPassword": "Nova senha",
"views.admin.Account.newPassword": "Nova senha",
"components.MailBox.nextMail": "Próximo",
"views.index.SimpleIndex.nextPage": "Próximo",
"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.",
"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",
"views.admin.UserOauth2Settings.oauth2Type": "Tipo de OAuth2",
"views.Header.ok": "OK",
"views.Admin.ok": "OK",
"views.index.AddressBar.ok": "OK",
"views.admin.SenderAccess.ok": "OK",
"views.common.Login.ok": "OK",
"views.admin.AccountSettings.create_address_subdomain_match_tip": "Afeta apenas a validação de domínio em /api/new_address e /admin/new_address. Exemplo: com isso ativado, foo.example.com pode corresponder a example.com.",
"components.AiExtractInfo.open": "Abrir",
"views.index.AddressBar.linkWithAddressCredential": "Abrir link de login automático por e-mail",
"views.admin.CreateAccount.linkWithAddressCredential": "Abrir link de login automático por e-mail",
"views.index.SendMail.options": "Opções",
"views.admin.SendMail.options": "Opções",
"components.AiExtractInfo.otherLink": "Outro link",
"views.user.UserSettings.passkeyCreated": "Passkey criada com sucesso",
"views.user.UserSettings.passkey_name": "Nome da passkey",
"views.user.UserLogin.password": "Senha",
"views.admin.UserManagement.password": "Senha",
"views.common.Login.password": "Senha",
"views.index.AccountSettings.passwordChanged": "Senha alterada com sucesso",
"views.common.Login.passwordLogin": "Login com senha",
"views.admin.Account.passwordResetSuccess": "Senha redefinida com sucesso",
"views.index.AccountSettings.passwordMismatch": "As senhas não coincidem",
"views.index.SendMail.successSend": "Verifique a caixa de saída. Se falhar, confira seu saldo ou tente novamente mais tarde.",
"views.admin.SendMail.successSend": "Verifique sua caixa de saída. Se falhar, tente novamente mais tarde.",
"views.user.UserLogin.pleaseCompleteTurnstile": "Conclua o turnstile",
"views.index.SimpleIndex.addressCredentialTip": "Copie a credencial do endereço de e-mail; você poderá usá-la para fazer login.",
"views.index.AddressBar.addressCredentialTip": "Copie a credencial do endereço de e-mail; você poderá usá-la para entrar na sua conta de e-mail.",
"views.admin.Account.addressCredentialTip": "Copie a credencial do endereço de e-mail; você poderá usá-la para entrar na sua conta de e-mail.",
"views.admin.CreateAccount.addressCredentialTip": "Copie a credencial do endereço de e-mail; você poderá usá-la para entrar na sua conta de e-mail.",
"views.Admin.accessTip": "Digite a senha de administrador",
"views.Header.accessTip": "Informe a senha de acesso correta",
"views.admin.AccountSettings.address_block_list_placeholder": "Informe as palavras-chave que deseja bloquear",
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
"views.user.UserLogin.pleaseInputCode": "Informe o código",
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
"views.user.UserLogin.pleaseInputEmail": "Informe o e-mail",
"views.user.UserLogin.pleaseInput": "Informe e-mail e senha",
"views.admin.Maintenance.tip": "Informe os dias",
"views.common.Login.getNewEmailTip1": "Informe o e-mail que deseja usar. Permitido apenas: ",
"views.common.Login.credentialInput": "Digite a credencial do endereço de e-mail",
"views.admin.SenderAccess.modalTip": "Informe o saldo de envio",
"views.user.UserLogin.pleaseLogin": "Faça login",
"views.common.Login.pleaseGetNewEmail": "Faça login ou clique em \"Criar novo e-mail\" para obter um novo endereço.",
"components.WebhookComponent.fillInDemoTip": "Altere a URL e as demais configurações para as suas",
"components.SendBox.pleaseSelectMail": "Selecione um e-mail para visualizar.",
"views.admin.Account.pleaseSelectAddress": "Selecione um endereço",
"components.MailBox.pleaseSelectMail": "Selecione um e-mail",
"views.admin.UserManagement.prefix": "Prefixo",
"components.WebhookComponent.presets": "Modelos",
"views.index.SendMail.preview": "Pré-visualização",
"views.admin.SendMail.preview": "Pré-visualização",
"views.admin.UserOauth2Settings.iconPreview": "Pré-visualização",
"components.MailBox.prevMail": "Anterior",
"views.index.SimpleIndex.prevPage": "Anterior",
"components.MailBox.query": "Consultar",
"views.user.UserMailBox.query": "Consultar",
"views.Index.query": "Consultar",
"views.admin.SendBox.query": "Consultar",
"views.admin.Account.query": "Consultar",
"views.admin.SenderAccess.query": "Consultar",
"views.admin.UserManagement.query": "Consultar",
"views.admin.Mails.query": "Consultar",
"views.Admin.qucickSetup": "Configuração rápida",
"views.index.SendMail.toMailEmpty": "O endereço do destinatário está vazio",
"views.admin.SendMail.toMailEmpty": "O endereço do destinatário está vazio",
"views.index.SendMail.toName": "Nome e endereço do destinatário; deixe o nome em branco para usar o e-mail",
"views.admin.SendMail.toName": "Nome e endereço do destinatário; deixe o nome em branco para usar o e-mail",
"components.MailBox.refresh": "Atualizar",
"components.Turnstile.refresh": "Atualizar",
"components.SendBox.refresh": "Atualizar",
"views.index.SimpleIndex.refreshMails": "Atualizar",
"components.MailBox.refreshAfter": "Atualizar em {msg} segundos",
"views.index.SimpleIndex.refreshAfter": "Atualizar em {msg} segundos",
"views.admin.AccountSettings.regex_too_long": "O padrão regex é muito longo (máx. 200 caracteres)",
"views.user.UserLogin.register": "Registrar",
"views.user.UserSettings.renamePasskey": "Renomear passkey",
"views.admin.UserOauth2Settings.userEmailReplace": "Modelo de substituição",
"components.MailBox.reply": "Responder",
"components.MailContentRenderer.reply": "Responder",
"views.index.SendMail.requestAccess": "Solicitar acesso",
"views.user.UserLogin.resetPassword": "Redefinir Senha",
"views.admin.Account.resetPassword": "Redefinir Senha",
"views.admin.UserManagement.resetPassword": "Redefinir Senha",
"views.index.SendMail.rich text": "Texto rico",
"views.admin.SendMail.rich text": "Texto rico",
"views.common.Appearance.right": "direita",
"views.admin.UserManagement.role": "Função",
"views.admin.RoleAddressConfig.role": "Função",
"views.Admin.roleAddressConfig": "Configuração de endereço por função",
"views.admin.AccountSettings.rule_index": "Regra",
"views.Index.s3Attachment": "Anexo S3",
"components.WebhookComponent.save": "Salvar",
"views.index.AutoReply.save": "Salvar",
"views.admin.AiExtractSettings.save": "Salvar",
"views.admin.UserOauth2Settings.save": "Salvar",
"views.admin.IpBlacklistSettings.save": "Salvar",
"views.admin.Telegram.save": "Salvar",
"views.admin.UserSettings.save": "Salvar",
"views.admin.RoleAddressConfig.save": "Salvar",
"views.admin.AccountSettings.save": "Salvar",
"views.admin.Maintenance.save": "Salvar",
"views.admin.Webhook.save": "Salvar",
"views.admin.Maintenance.saveSuccess": "Salvo com sucesso",
"views.admin.UserOauth2Settings.successTip": "Salvo com sucesso",
"views.admin.IpBlacklistSettings.successTip": "Salvo com sucesso",
"views.admin.UserSettings.successTip": "Salvo com sucesso",
"views.admin.AccountSettings.successTip": "Salvo com sucesso",
"components.MailBox.saveToS3": "Salvar no S3",
"components.MailContentRenderer.saveToS3": "Salvar no S3",
"views.Index.saveToS3Success": "salvo no S3 com sucesso",
"components.MailBox.selectAll": "Selecionar toda esta página",
"components.SendBox.selectAll": "Selecionar toda esta página",
"views.admin.Account.selectAll": "Selecionar toda esta página",
"views.admin.AccountSettings.select_domain": "Selecionar domínio",
"views.admin.Account.selectedItems": "Selecionado",
"views.index.SendMail.send": "Enviar",
"views.admin.SendMail.send": "Enviar",
"views.Index.sendbox": "Caixa de saída",
"views.Admin.sendBox": "Caixa de saída",
"views.user.AddressManagement.send_count": "Quantidade enviada",
"views.admin.Account.send_count": "Quantidade enviada",
"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.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",
"views.Admin.senderAccess": "Controle de acesso de envio",
"views.admin.SendMail.fromMailEmpty": "O endereço do remetente está vazio",
"views.index.AutoReply.sourcePrefix": "Filtro de remetente",
"components.AiExtractInfo.serviceLink": "Link do serviço",
"views.index.AutoReply.settings": "Configurações",
"views.index.AccountSettings.showAddressCredential": "Mostrar credencial do endereço",
"components.MailBox.attachments": "Mostrar anexos",
"components.MailBox.showHtmlMail": "Mostrar e-mail HTML",
"components.MailContentRenderer.showHtmlMail": "Mostrar e-mail HTML",
"views.admin.Account.showCredential": "Mostrar credencial do endereço de e-mail",
"views.user.UserSettings.showPasskeyList": "Mostrar lista de passkeys",
"components.MailBox.showTextMail": "Mostrar e-mail em texto",
"components.MailContentRenderer.showTextMail": "Mostrar e-mail em texto",
"views.Index.enterSimpleMode": "Modo simples",
"components.MailContentRenderer.size": "Tamanho",
"views.admin.Account.source_meta": "Origem",
"views.admin.AccountSettings.source_patterns": "Regex do endereço de origem (opcional)",
"views.admin.Maintenance.sqlStatement": "Comando SQL (somente DELETE)",
"views.user.UserOauth2Callback.stateNotMatch": "estado não corresponde",
"views.Admin.statistics": "Estatísticas",
"views.Header.status": "Status",
"views.admin.IpBlacklistSettings.enable_whitelist_tip": "Modo estrito: apenas IPs que corresponderem à lista branca poderão acessar APIs com limite. Todos os outros IPs serão negados.",
"views.index.AutoReply.subject": "Assunto",
"views.index.SendMail.subject": "Assunto",
"views.admin.SendMail.subject": "Assunto",
"views.index.SendMail.subjectEmpty": "O assunto está vazio",
"views.admin.SendMail.subjectEmpty": "O assunto está vazio",
"components.AiExtractInfo.subscriptionLink": "Link de assinatura",
"views.user.AddressManagement.success": "sucesso",
"views.index.TelegramAddress.success": "sucesso",
"views.index.LocalAddress.success": "sucesso",
"views.admin.UserAddressManagement.success": "sucesso",
"components.WebhookComponent.successTip": "Sucesso",
"components.MailBox.success": "Sucesso",
"components.SendBox.success": "Sucesso",
"views.index.AccountSettings.success": "Sucesso",
"views.index.AutoReply.success": "Sucesso",
"views.admin.AiExtractSettings.successTip": "Sucesso",
"views.admin.Account.success": "Sucesso",
"views.admin.SenderAccess.success": "Sucesso",
"views.admin.UserManagement.success": "Sucesso",
"views.admin.Telegram.successTip": "Sucesso",
"views.admin.RoleAddressConfig.successTip": "Sucesso",
"views.admin.Webhook.successTip": "Sucesso",
"views.admin.CreateAccount.successTip": "Criado com sucesso",
"views.admin.Telegram.globalMailPushListTip": "Suporta chat_id de conversa privada/grupo/canal. Envie uma mensagem ao bot e acesse este link para ver o chat_id: https://api.telegram.org/bot<Substitua pelo seu BOT TOKEN>/getUpdates",
"views.user.AddressManagement.targetUserEmail": "E-mail do usuário de destino",
"views.admin.Telegram.telegramAllowList": "Lista branca do Telegram (informar manualmente o Chat ID)",
"views.Admin.telegram": "Bot do Telegram",
"views.admin.Telegram.miniAppUrl": "URL do Mini App do Telegram",
"components.WebhookComponent.test": "Teste",
"views.index.SendMail.text": "Texto",
"views.admin.SendMail.text": "Texto",
"views.user.UserSettings.passordTip": "O servidor recebe apenas o hash da senha, nunca a senha em texto puro, portanto não pode vê-la nem recuperá-la. Se o administrador ativar a verificação por e-mail, você poderá redefini-la no modo anônimo.",
"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.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",
"views.user.AddressManagement.transferAddress": "Transferir endereço",
"views.user.AddressManagement.transferAddressTip": "Transferir este endereço para outro usuário irá removê-lo da sua conta e movê-lo para a conta dele. Tem certeza de que deseja transferi-lo?",
"views.common.Appearance.useSideMargin": "Ativar margens laterais à esquerda e à direita da página",
"views.admin.AiExtractSettings.manualInputPrompt": "Digite e pressione Enter para adicionar",
"views.admin.UserOauth2Settings.manualInputPrompt": "Digite e pressione Enter para adicionar",
"views.admin.Telegram.manualInputPrompt": "Digite e pressione Enter para adicionar",
"views.admin.UserSettings.manualInputPrompt": "Digite e pressione Enter para adicionar",
"views.admin.AccountSettings.manualInputPrompt": "Digite e pressione Enter para adicionar",
"views.admin.Webhook.manualInputPrompt": "Digite e pressione Enter para adicionar",
"views.admin.IpBlacklistSettings.manualInputPrompt": "Digite o padrão e pressione Enter para adicionar",
"views.user.AddressManagement.unbindAddress": "Desvincular endereço",
"views.index.TelegramAddress.unbindMailAddress": "Desvincular endereço de e-mail",
"views.index.LocalAddress.unbindMailAddress": "Desvincular credencial do endereço de e-mail",
"components.MailBox.unselectAll": "Desmarcar tudo",
"components.SendBox.unselectAll": "Desmarcar tudo",
"views.admin.Account.unselectAll": "Desmarcar tudo",
"views.admin.Account.updated_at": "Atualizado em",
"views.user.UserSettings.updated_at": "Atualizado em",
"components.WebhookComponent.urlMissing": "URL é obrigatória",
"views.common.Appearance.useIframeShowMail": "Usar iframe para mostrar e-mail HTML",
"views.admin.CreateAccount.enableRandomSubdomain": "Usar subdomínio aleatório",
"views.common.Login.enableRandomSubdomain": "Usar subdomínio aleatório",
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com",
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
"views.common.Appearance.useUTCDate": "Usar data UTC",
"views.Header.user": "Usuário",
"views.Admin.user": "Usuário",
"components.AddressSelect.userAddresses": "Endereços do usuário",
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
"views.admin.Statistics.userCount": "Quantidade de usuários",
"views.admin.UserManagement.user_email": "E-mail do usuário",
"views.index.AddressBar.userLogin": "Login do usuário",
"views.Admin.user_management": "Gerenciamento de usuários",
"views.Admin.user_settings": "Configurações do usuário",
"views.User.user_settings": "Configurações do usuário",
"components.AiExtractInfo.authCode": "Código de verificação",
"views.user.UserLogin.verifyCode": "Código de verificação",
"views.user.UserLogin.verifyCodeSent": "Código de verificação enviado, expira em {timeout} segundos",
"views.admin.AccountSettings.verified_address_list": "Lista de endereços verificados (pode enviar e-mail pela API interna do cf)",
"views.admin.UserSettings.verifyMailSender": "Verificar remetente do e-mail",
"components.MailContentRenderer.attachments": "Ver anexos",
"views.admin.Account.viewMails": "Ver e-mails",
"views.admin.Account.viewSendBox": "Ver caixa de saída",
"views.user.UserLogin.waitforVerifyCode": "Aguarde {timeout} segundos",
"views.admin.Webhook.webhookAllowList": "Lista de permissão do webhook (digite o e-mail autorizado e pressione Enter)",
"views.admin.Webhook.notEnabled": "O webhook não está habilitado",
"components.WebhookComponent.notEnabled": "O webhook não está habilitado para você",
"views.Index.webhookSettings": "Configurações de webhook",
"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.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.",
"views.common.Login.getNewEmailTip3": "Você pode escolher um domínio na lista suspensa.",
"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",
"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"
}

File diff suppressed because it is too large Load Diff

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