Compare commits

..

30 Commits

Author SHA1 Message Date
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
118 changed files with 4397 additions and 1116 deletions

View File

@@ -0,0 +1,27 @@
---
name: 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:
- Copy changelog sections verbatim (Features, Bug Fixes, Testing, Improvements). 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

@@ -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,24 +16,25 @@ 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@v4
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
@@ -49,8 +50,11 @@ jobs:
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
@@ -63,14 +67,17 @@ jobs:
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

@@ -15,20 +15,20 @@ jobs:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- 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@v4
name: Install pnpm
id: pnpm-install
with:
version: 8
version: 10
run_install: false
- name: check github release done

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Run E2E tests
run: |

View File

@@ -14,17 +14,17 @@ jobs:
runs-on: ubuntu-latest
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@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}
@@ -51,17 +51,17 @@ jobs:
runs-on: ubuntu-latest
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@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Deploy Telegram Frontend for ${{ github.ref_name }}

View File

@@ -32,17 +32,17 @@ jobs:
runs-on: ubuntu-latest
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@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Deploy Frontend for ${{ github.ref_name }}

View File

@@ -21,26 +21,26 @@ 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

View File

@@ -12,7 +12,7 @@ jobs:
if: ${{ github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Sync upstream changes
id: sync

View File

@@ -10,17 +10,17 @@ jobs:
runs-on: ubuntu-latest
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@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Build Frontend
@@ -39,17 +39,17 @@ jobs:
runs-on: ubuntu-latest
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@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: Build Telegram Frontend
@@ -68,17 +68,17 @@ jobs:
runs-on: ubuntu-latest
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@v4
name: Install pnpm
with:
version: 8
version: 10
run_install: false
- name: cp wrangler.toml

View File

@@ -2,11 +2,46 @@
# CHANGE LOG
<p align="center">
<a href="CHANGELOG.md">🇨🇳 中文</a> |
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
<a href="CHANGELOG.md">中文</a> |
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.4.0(main)
## v1.5.0(main)
### 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

View File

@@ -2,11 +2,46 @@
# CHANGE LOG
<p align="center">
<a href="CHANGELOG.md">🇨🇳 中文</a> |
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
<a href="CHANGELOG.md">中文</a> |
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.4.0(main)
## v1.5.0(main)
### 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

View File

@@ -11,6 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **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
@@ -26,19 +27,58 @@ Run inside each subfolder with `pnpm`:
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`).
## Auth Headers
- Address JWT: `x-user-token`
- User JWT: `x-user-access-token`
- Admin: `x-admin-auth`
- Language: `x-lang`
## Commits & PRs
- Use Conventional Commits: `feat:`, `fix:`, `docs:`, `style:`, `refactor:`, `perf:`.
@@ -57,10 +97,6 @@ After completing any feature, bug fix, or improvement, **always check**:
- `api/` — API reference docs
3. **Both languages** — docs and changelogs exist in Chinese and English; always update both.
## Testing
No formal test runner. Validate with local dev servers and key flows (login, inbox, send/receive).
## Config
- Worker settings in `worker/wrangler.toml` (see `wrangler.toml.template` for bindings).

View File

@@ -109,6 +109,7 @@
- [x] 使用 `rust wasm` 解析邮件解析速度快几乎所有邮件都能解析node 的解析模块解析邮件失败的邮件rust wasm 也能解析成功
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- [x] 支持为指定基础域名创建随机二级域名邮箱地址,更适合收件隔离场景
- [x] 支持发送邮件,支持 `DKIM` 验证
- [x] 支持 `SMTP``Resend` 等多种发送方式
- [x] 增加查看 `附件` 功能,支持附件图片显示

View File

@@ -109,6 +109,7 @@ Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
- [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

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,6 +4,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
source TEXT,
address TEXT,
raw TEXT,
raw_blob BLOB,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,5 +1,6 @@
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
@@ -9,14 +10,37 @@ RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falli
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.
# Wrap the original config instead of sed-patching it — survives reformats.
# 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";\
config.server = { ...config.server, allowedHosts: true };\
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
ENV VITE_API_BASE=http://worker:8787
# Empty VITE_API_BASE so frontend uses same-origin (proxied through Vite)
ENV VITE_API_BASE=
EXPOSE 5173

View File

@@ -11,7 +11,8 @@ RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falli
COPY worker/src/ src/
COPY worker/tsconfig.json ./
COPY e2e/fixtures/wrangler.toml.e2e wrangler.toml
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
COPY ${WRANGLER_TOML} wrangler.toml
EXPOSE 8787

View File

@@ -20,6 +20,58 @@ services:
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
frontend:
build:
context: ..
@@ -73,7 +125,10 @@ services:
dockerfile: e2e/Dockerfile.e2e
environment:
WORKER_URL: http://worker:8787
FRONTEND_URL: http://frontend:5173
WORKER_URL_SUBDOMAIN: http://worker-subdomain:8789
WORKER_URL_ENV_OFF: http://worker-env-off:8790
WORKER_GZIP_URL: http://worker-gzip:8788
FRONTEND_URL: https://frontend:5173
MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy
SMTP_PROXY_SMTP_PORT: "8025"
@@ -85,6 +140,12 @@ services:
depends_on:
worker:
condition: service_healthy
worker-subdomain:
condition: service_healthy
worker-env-off:
condition: service_healthy
worker-gzip:
condition: service_healthy
frontend:
condition: service_started
smtp-proxy:

View File

@@ -2,6 +2,9 @@ import { APIRequestContext } from '@playwright/test';
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 FRONTEND_URL = process.env.FRONTEND_URL!;
export const MAILPIT_API = process.env.MAILPIT_API!;
export const TEST_DOMAIN = 'test.example.com';
@@ -16,7 +19,7 @@ export async function createTestAddress(
ctx: APIRequestContext,
name: string,
domain: string = TEST_DOMAIN
): Promise<{ jwt: string; address: string }> {
): 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 },
@@ -25,7 +28,7 @@ export async function createTestAddress(
throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`);
}
const body = await res.json();
return { jwt: body.jwt, address: body.address };
return { jwt: body.jwt, address: body.address, address_id: body.address_id };
}
/**

View File

@@ -16,6 +16,7 @@ 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 = """

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

18
e2e/package-lock.json generated
View File

@@ -6,8 +6,8 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.2.12",
"nodemailer": "^8.0.1"
"imapflow": "^1.2.18",
"nodemailer": "^8.0.4"
},
"devDependencies": {
"@playwright/test": "1.58.2",
@@ -129,9 +129,9 @@
}
},
"node_modules/imapflow": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.12.tgz",
"integrity": "sha512-UX8qCKXZk2xExe/x8KPTSbhROdtUGP13bSLSjT9Sb3YwGuryD4aFNlGhbWBW5B1GtgHMRxVv9yvl61RqXgIQtQ==",
"version": "1.2.18",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.18.tgz",
"integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
@@ -140,7 +140,7 @@
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "8.0.1",
"nodemailer": "8.0.4",
"pino": "10.3.1",
"socks": "2.8.7"
}
@@ -191,9 +191,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"

View File

@@ -13,7 +13,7 @@
"ws": "^8.18.0"
},
"dependencies": {
"imapflow": "^1.2.12",
"nodemailer": "^8.0.1"
"imapflow": "^1.2.18",
"nodemailer": "^8.0.4"
}
}

View File

@@ -1,6 +1,7 @@
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({
@@ -16,6 +17,13 @@ export default defineConfig({
baseURL: WORKER_BASE,
},
},
{
name: 'api-gzip',
testDir: './tests/api-gzip',
use: {
baseURL: WORKER_GZIP_BASE,
},
},
{
name: 'smtp-proxy',
testDir: './tests/smtp-proxy',
@@ -29,6 +37,8 @@ export default defineConfig({
use: {
baseURL: FRONTEND_BASE,
...devices['Desktop Chrome'],
// Accept self-signed cert from Docker frontend (HTTPS for WebAuthn)
ignoreHTTPSErrors: true,
},
},
],

View File

@@ -14,9 +14,54 @@ for i in $(seq 1 60); do
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 -sf "$FRONTEND_URL" > /dev/null 2>&1; then
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
echo " Frontend ready after ${i}s"
break
fi
@@ -44,5 +89,26 @@ 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,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

@@ -4,9 +4,10 @@ import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, requestSendA
test.describe('Address Lifecycle', () => {
test('create address, request send access, fetch settings, then delete', async ({ request }) => {
// Create address
const { jwt, address } = await createTestAddress(request, 'lifecycle-test');
const { jwt, address, address_id } = await createTestAddress(request, 'lifecycle-test');
expect(address).toContain('@' + TEST_DOMAIN);
expect(jwt).toBeTruthy();
expect(address_id).toBeGreaterThan(0);
// Request send access (creates address_sender row with DEFAULT_SEND_BALANCE)
await requestSendAccess(request, jwt);

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,132 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
import * as crypto from 'crypto';
/**
* SHA-256 hash matching frontend hashPassword utility.
*/
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
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: 'addr-pass-123' },
});
// Login with cf_token field present but empty
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: {
email: address,
password: 'addr-pass-123',
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,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,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,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

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -23,21 +23,21 @@
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@simplewebauthn/browser": "10.0.0",
"@unhead/vue": "^2.1.10",
"@simplewebauthn/browser": "13.2.2",
"@unhead/vue": "^2.1.12",
"@vueuse/core": "^14.2.1",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.13.6",
"dompurify": "^3.3.2",
"dompurify": "^3.3.3",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.1",
"naive-ui": "^2.43.2",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.3",
"vooks": "^0.2.12",
"vue": "^3.5.29",
"vue": "^3.5.30",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.2.8",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
@@ -54,7 +54,7 @@
"vitest": "^3.2.4",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0",
"wrangler": "^4.70.0"
"wrangler": "^4.72.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

485
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -74,6 +74,7 @@ const getOpenSettings = async (message, notification) => {
maxAddressLen: res["maxAddressLen"] || 30,
needAuth: res["needAuth"] || false,
defaultDomains: res["defaultDomains"] || [],
randomSubdomainDomains: res["randomSubdomainDomains"] || [],
domains: res["domains"].map((domain, index) => {
return {
label: domainLabels.length > index ? domainLabels[index] : domain,
@@ -93,6 +94,7 @@ const getOpenSettings = async (message, notification) => {
isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;

View File

@@ -17,17 +17,21 @@ const { locale, t } = useI18n({
}
});
const containerId = `cf-turnstile-${Math.random().toString(36).slice(2, 9)}`
const cfTurnstileId = ref("")
const turnstileLoading = ref(false)
const refresh = () => checkCfTurnstile(true)
defineExpose({ refresh })
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;
@@ -38,7 +42,7 @@ const checkCfTurnstile = async (remove) => {
window.turnstile.remove(cfTurnstileId.value);
}
cfTurnstileId.value = window.turnstile.render(
"#cf-turnstile",
`#${containerId}`,
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
@@ -68,7 +72,7 @@ onMounted(() => {
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<n-flex vertical>
<div id="cf-turnstile"></div>
<div :id="containerId"></div>
<n-button text @click="checkCfTurnstile(true)">
{{ t('refresh') }}
</n-button>

View File

@@ -117,6 +117,55 @@ const presets: WebhookPreset[] = [
}, 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 presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({

View File

@@ -28,6 +28,8 @@ export const useGlobalState = createGlobalState(
enableIndexAbout: false,
/** @type {string[]} */
defaultDomains: [],
/** @type {string[]} */
randomSubdomainDomains: [],
/** @type {Array<{label: string, value: string}>} */
domains: [],
copyright: 'Dream Hunter',
@@ -39,6 +41,7 @@ export const useGlobalState = createGlobalState(
disableAdminPasswordCheck: false,
enableAddressPassword: false,
statusUrl: '',
enableGlobalTurnstileCheck: false,
})
const settings = ref({
fetched: false,
@@ -111,8 +114,18 @@ export const useGlobalState = createGlobalState(
);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
const userOauth2SessionState = useSessionStorage('userOauth2SessionState', '');
const userOauth2SessionClientID = useSessionStorage('userOauth2SessionClientID', '');
const _oauth2StateSession = useSessionStorage('userOauth2SessionState', '');
const _oauth2StateFallback = useStorage('userOauth2SessionState_fb', '');
const userOauth2SessionState = computed({
get: () => _oauth2StateSession.value || _oauth2StateFallback.value,
set: (v) => { _oauth2StateSession.value = v; _oauth2StateFallback.value = v; }
});
const _oauth2ClientIDSession = useSessionStorage('userOauth2SessionClientID', '');
const _oauth2ClientIDFallback = useStorage('userOauth2SessionClientID_fb', '');
const userOauth2SessionClientID = computed({
get: () => _oauth2ClientIDSession.value || _oauth2ClientIDFallback.value,
set: (v) => { _oauth2ClientIDSession.value = v; _oauth2ClientIDFallback.value = v; }
});
const browserFingerprint = ref('');
return {
isDark,

View File

@@ -5,7 +5,8 @@ import { useRouter } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang } from '../utils'
import { getRouterPathWithLang, hashPassword } from '../utils'
import Turnstile from '../components/Turnstile.vue'
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
@@ -44,12 +45,23 @@ const SendMail = defineAsyncComponent(() => {
.finally(() => loading.value = false);
});
const cfToken = ref('')
const turnstileRef = ref(null)
const authFunc = async () => {
try {
await api.fetch('/open_api/admin_login', {
method: 'POST',
body: JSON.stringify({
password: await hashPassword(tmpAdminAuth.value),
cf_token: cfToken.value
})
});
adminAuth.value = tmpAdminAuth.value;
location.reload()
} catch (error) {
message.error(error.message || "error");
turnstileRef.value?.refresh?.();
}
}
@@ -169,6 +181,8 @@ const currentLoginMethod = computed(() => {
})
onMounted(async () => {
// make sure openSettings is fetched for turnstile check
if (!openSettings.value.fetched) await api.getOpenSettings(message);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
})
@@ -180,6 +194,7 @@ onMounted(async () => {
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="tmpAdminAuth" type="password" show-password-on="click" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action>
<n-button @click="authFunc" type="primary" :loading="loading">
{{ t('ok') }}

View File

@@ -12,7 +12,8 @@ import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang } from '../utils'
import { getRouterPathWithLang, hashPassword } from '../utils'
import Turnstile from '../components/Turnstile.vue'
const message = useMessage()
const notification = useNotification()
@@ -32,11 +33,22 @@ const menuValue = computed(() => {
return "home";
});
const cfToken = ref('')
const turnstileRef = ref(null)
const authFunc = async () => {
try {
await api.fetch('/open_api/site_login', {
method: 'POST',
body: JSON.stringify({
password: await hashPassword(auth.value),
cf_token: cfToken.value
})
});
location.reload()
} catch (error) {
message.error(error.message || "error");
turnstileRef.value?.refresh?.();
}
}
@@ -287,6 +299,7 @@ onMounted(async () => {
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="password" show-password-on="click" />
<Turnstile ref="turnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="cfToken" />
<template #action>
<n-button :loading="loading" @click="authFunc" type="primary">
{{ t('ok') }}

View File

@@ -114,6 +114,8 @@ const selectedCount = computed(() => checkedRowKeys.value.length);
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
const addressQuery = ref("")
const sortBy = ref("")
const sortOrder = ref("")
const data = ref([])
const count = ref(0)
@@ -290,10 +292,12 @@ const fetchData = async () => {
+ `?limit=${pageSize.value}`
+ `&offset=${(page.value - 1) * pageSize.value}`
+ (addressQuery.value ? `&query=${addressQuery.value}` : "")
+ (sortBy.value ? `&sort_by=${sortBy.value}` : "")
+ (sortOrder.value ? `&sort_order=${sortOrder.value}` : "")
);
data.value = results;
if (addressCount > 0) {
count.value = addressCount;
if (page.value === 1 || addressCount > 0) {
count.value = addressCount ?? 0;
}
} catch (error) {
console.error(error);
@@ -301,29 +305,57 @@ const fetchData = async () => {
}
}
const columns = [
const searchData = () => {
if (page.value === 1) {
fetchData();
} else {
page.value = 1;
}
}
const handleSorterChange = (sorter) => {
sortBy.value = sorter.columnKey || "";
sortOrder.value = sorter.order || "";
if (page.value === 1) {
fetchData();
} else {
page.value = 1;
}
}
const columns = computed(() => [
{
type: 'selection'
},
{
title: "ID",
key: "id"
key: "id",
sorter: true,
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
},
{
title: t('name'),
key: "name"
key: "name",
sorter: true,
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
},
{
title: t('created_at'),
key: "created_at"
key: "created_at",
sorter: true,
sortOrder: sortBy.value === 'created_at' ? sortOrder.value : false
},
{
title: t('updated_at'),
key: "updated_at"
key: "updated_at",
sorter: true,
sortOrder: sortBy.value === 'updated_at' ? sortOrder.value : false
},
{
title: t('source_meta'),
key: "source_meta",
sorter: true,
sortOrder: sortBy.value === 'source_meta' ? sortOrder.value : false,
render(row) {
const val = row.source_meta;
if (!val) return '';
@@ -342,6 +374,8 @@ const columns = [
{
title: t('mail_count'),
key: "mail_count",
sorter: true,
sortOrder: sortBy.value === 'mail_count' ? sortOrder.value : false,
render(row) {
return h(NButton,
{
@@ -368,6 +402,8 @@ const columns = [
{
title: t('send_count'),
key: "send_count",
sorter: true,
sortOrder: sortBy.value === 'send_count' ? sortOrder.value : false,
render(row) {
return h(NButton,
{
@@ -497,7 +533,7 @@ const columns = [
])
}
}
]
])
watch([page, pageSize], async () => {
await fetchData()
@@ -560,8 +596,8 @@ onMounted(async () => {
</n-modal>
<n-input-group style="margin-bottom: 10px;">
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')"
@keydown.enter="fetchData" />
<n-button @click="fetchData" type="primary" tertiary>
@keydown.enter="searchData" />
<n-button @click="searchData" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
@@ -605,7 +641,7 @@ onMounted(async () => {
</n-pagination>
</div>
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
:row-key="row => row.id" embedded />
:row-key="row => row.id" remote @update:sorter="handleSorterChange" embedded />
</div>
<!-- Multi-action progress modal -->

View File

@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref, h } from 'vue';
import { computed, onMounted, ref, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
@@ -46,6 +46,14 @@ const { t } = useI18n({
regex_invalid: 'Invalid regex pattern',
forward_address_required: 'Forward address is required',
rule_index: 'Rule',
create_address_subdomain_match: 'Allow Subdomain Suffix Match When Creating Address',
create_address_subdomain_match_tip: 'Only affects /api/new_address and /admin/new_address domain validation. Example: when enabled, foo.example.com can match configured base domain example.com.',
create_address_subdomain_match_note: 'This is different from RANDOM_SUBDOMAIN_DOMAINS: this switch allows API callers to specify custom subdomains directly, while random subdomain only auto-generates one during creation.',
create_address_subdomain_match_follow_env: 'Follow Environment Variable',
create_address_subdomain_match_force_enable: 'Force Enable',
create_address_subdomain_match_force_disable: 'Force Disable',
create_address_subdomain_match_follow_env_note: 'Choosing "Follow Environment Variable" clears the admin override and returns to the unset state. The effective result is still controlled by the Worker env and the precedence rules.',
create_address_subdomain_match_env_locked: 'Worker env ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH is currently false. The saved admin switch can be modified, but it will not take effect until env is enabled or removed.',
},
zh: {
tip: '您可以手动输入以下多选输入框, 回车增加',
@@ -82,6 +90,14 @@ const { t } = useI18n({
regex_invalid: '无效的正则表达式',
forward_address_required: '转发地址不能为空',
rule_index: '规则',
create_address_subdomain_match: '创建邮箱时允许子域名后缀匹配',
create_address_subdomain_match_tip: '仅影响 /api/new_address 和 /admin/new_address 的域名校验。例如开启后foo.example.com 可以匹配已配置的基础域名 example.com。',
create_address_subdomain_match_note: '这与 RANDOM_SUBDOMAIN_DOMAINS 不同:这里允许 API 调用方直接指定自定义子域名;随机子域名功能只是在创建时自动补一个随机子域名。',
create_address_subdomain_match_follow_env: '跟随环境变量',
create_address_subdomain_match_force_enable: '强制开启',
create_address_subdomain_match_force_disable: '强制关闭',
create_address_subdomain_match_follow_env_note: '选择“跟随环境变量”会清空后台覆盖,恢复为未设置状态;最终是否开启仍由 Worker env 和优先级规则决定。',
create_address_subdomain_match_env_locked: '当前 Worker 环境变量 ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH 为 false。后台开关仍可保存但在 env 打开或移除前不会生效。',
}
}
});
@@ -95,6 +111,38 @@ const emailRuleSettings = ref({
blockReceiveUnknowAddressEmail: false,
emailForwardingList: []
})
const ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE = {
FOLLOW_ENV: 'follow_env',
FORCE_ENABLE: 'force_enable',
FORCE_DISABLE: 'force_disable'
}
const addressCreationSubdomainMatchMode = ref(ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV)
const addressCreationSubdomainMatchStatus = ref({
envConfigured: false,
envEnabled: false,
storedEnabled: undefined,
effectiveEnabled: false
})
const subdomainMatchEnvLocked = computed(() => {
return addressCreationSubdomainMatchStatus.value.envConfigured
&& !addressCreationSubdomainMatchStatus.value.envEnabled
})
const subdomainMatchModeOptions = computed(() => {
return [
{
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV,
label: t('create_address_subdomain_match_follow_env')
},
{
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE,
label: t('create_address_subdomain_match_force_enable')
},
{
value: ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE,
label: t('create_address_subdomain_match_force_disable')
}
]
})
const showEmailForwardingModal = ref(false)
const emailForwardingList = ref([])
@@ -246,8 +294,27 @@ const saveEmailForwardingConfig = () => {
showEmailForwardingModal.value = false
}
const getSubdomainMatchModeByStoredValue = (storedEnabled) => {
if (storedEnabled === true) {
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE
}
if (storedEnabled === false) {
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE
}
return ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FOLLOW_ENV
}
const fetchData = async () => {
const getSubdomainMatchPayloadValue = (mode) => {
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_ENABLE) {
return true
}
if (mode === ADDRESS_CREATION_SUBDOMAIN_MATCH_MODE.FORCE_DISABLE) {
return false
}
return null
}
const fetchData = async ({ suppressErrorMessage = false } = {}) => {
try {
const res = await api.fetch(`/admin/account_settings`)
addressBlockList.value = res.blockList || []
@@ -259,33 +326,63 @@ const fetchData = async () => {
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
}
addressCreationSubdomainMatchStatus.value = {
envConfigured: !!res.addressCreationSubdomainMatchStatus?.envConfigured,
envEnabled: !!res.addressCreationSubdomainMatchStatus?.envEnabled,
storedEnabled: typeof res.addressCreationSubdomainMatchStatus?.storedEnabled === 'boolean'
? res.addressCreationSubdomainMatchStatus.storedEnabled
: undefined,
effectiveEnabled: !!res.addressCreationSubdomainMatchStatus?.effectiveEnabled
}
addressCreationSubdomainMatchMode.value = getSubdomainMatchModeByStoredValue(
addressCreationSubdomainMatchStatus.value.storedEnabled
)
} catch (error) {
message.error(error.message || "error");
if (!suppressErrorMessage) {
message.error(error.message || "error");
}
throw error
}
}
const save = async () => {
try {
const payload = {
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
noLimitSendAddressList: noLimitSendAddressList.value || [],
emailRuleSettings: emailRuleSettings.value,
addressCreationSettings: {
enableSubdomainMatch: getSubdomainMatchPayloadValue(addressCreationSubdomainMatchMode.value)
}
}
await api.fetch(`/admin/account_settings`, {
method: 'POST',
body: JSON.stringify({
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
noLimitSendAddressList: noLimitSendAddressList.value || [],
emailRuleSettings: emailRuleSettings.value,
})
body: JSON.stringify(payload)
})
message.success(t('successTip'))
} catch (error) {
message.error(error.message || "error");
return
}
try {
await fetchData({ suppressErrorMessage: true })
} catch (error) {
console.warn('Failed to refresh account settings after save', error)
message.warning(error.message || "error");
}
}
onMounted(async () => {
await fetchData();
try {
await fetchData();
} catch {
// 首次加载失败时,错误提示已经在 fetchData 内部统一处理,这里无需重复提示。
}
})
</script>
@@ -352,6 +449,29 @@ onMounted(async () => {
<n-form-item-row :label="t('block_receive_unknow_address_email')">
<n-switch v-model:value="emailRuleSettings.blockReceiveUnknowAddressEmail" :round="false" />
</n-form-item-row>
<n-form-item-row :label="t('create_address_subdomain_match')">
<n-flex vertical style="width: 100%;">
<n-radio-group v-model:value="addressCreationSubdomainMatchMode">
<n-space vertical size="small">
<n-radio v-for="item in subdomainMatchModeOptions" :key="item.value" :value="item.value">
{{ item.label }}
</n-radio>
</n-space>
</n-radio-group>
<n-text depth="3">
{{ t('create_address_subdomain_match_tip') }}
</n-text>
<n-text depth="3">
{{ t('create_address_subdomain_match_note') }}
</n-text>
<n-text depth="3">
{{ t('create_address_subdomain_match_follow_env_note') }}
</n-text>
<n-alert v-if="subdomainMatchEnvLocked" type="warning" :show-icon="false" :bordered="false">
{{ t('create_address_subdomain_match_env_locked') }}
</n-alert>
</n-flex>
</n-form-item-row>
<n-form-item-row :label="t('email_forwarding_config')">
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
</n-form-item-row>

View File

@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
@@ -22,6 +22,8 @@ const { t } = useI18n({
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
addressPassword: 'Address Password',
linkWithAddressCredential: 'Open to auto login email link',
enableRandomSubdomain: 'Use Random Subdomain',
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
},
zh: {
address: '地址',
@@ -33,11 +35,14 @@ const { t } = useI18n({
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
addressPassword: '地址密码',
linkWithAddressCredential: '打开即可自动登录邮箱的链接',
enableRandomSubdomain: '启用随机子域名',
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
}
}
});
const enablePrefix = ref(true)
const enableRandomSubdomain = ref(false)
const emailName = ref("")
const emailDomain = ref("")
const showReultModal = ref(false)
@@ -45,6 +50,19 @@ const result = ref("")
const addressPassword = ref("")
const createdAddress = ref("")
const canUseRandomSubdomain = computed(() => {
if (!emailDomain.value) {
return false
}
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value)
})
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false
}
})
const newEmail = async () => {
if (!emailName.value || !emailDomain.value) {
message.error(t('fillInAllFields'))
@@ -55,6 +73,7 @@ const newEmail = async () => {
method: 'POST',
body: JSON.stringify({
enablePrefix: enablePrefix.value,
enableRandomSubdomain: enableRandomSubdomain.value,
name: emailName.value,
domain: emailDomain.value,
})
@@ -119,6 +138,14 @@ onMounted(async () => {
:options="openSettings.domains" />
</n-input-group>
</n-form-item-row>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
</n-form-item-row>
<n-button @click="newEmail" type="primary" block :loading="loading">
{{ t('creatNewEmail') }}
</n-button>

View File

@@ -21,7 +21,7 @@ const { t } = useI18n({
manualInputPrompt: 'Type and press Enter to add',
mailAllowList: 'Mail Address Allow List',
maxAddressCount: 'Maximum number of email addresses that can be binded',
emailCheckRegex: 'Email Check Regex (e.g. ^[^.]+@.+$ to disallow dots before @)',
emailCheckRegex: "Email Check Regex (e.g. ^[^.]+{'@'}.+$ to disallow dots before {'@'})",
enableEmailCheckRegex: 'Enable Email Check Regex',
},
zh: {
@@ -35,7 +35,7 @@ const { t } = useI18n({
manualInputPrompt: '输入后按回车键添加',
mailAllowList: '邮件地址白名单',
maxAddressCount: '可绑定最大邮箱地址数量',
emailCheckRegex: '邮箱正则校验 (例如 ^[^.]+@.+$ 禁止@前面有.)',
emailCheckRegex: "邮箱正则校验 (例如 ^[^.]+{'@'}.+$ 禁止{'@'}前面有.)",
enableEmailCheckRegex: '启用邮箱正则校验',
}
}
@@ -132,14 +132,14 @@ onMounted(async () => {
</n-input-group>
</n-form-item-row>
<n-form-item-row :label="t('enableEmailCheckRegex')">
<n-input-group>
<n-checkbox v-model:checked="userSettings.enableEmailCheckRegex" style="width: 20%;">
<n-flex align="center" :wrap="false" style="width: 100%;">
<n-checkbox v-model:checked="userSettings.enableEmailCheckRegex" style="flex: 0 0 auto;">
{{ t('enable') }}
</n-checkbox>
<n-input v-model:value="userSettings.emailCheckRegex"
v-if="userSettings.enableEmailCheckRegex"
style="width: 80%;" :placeholder="t('emailCheckRegex')" />
</n-input-group>
v-show="userSettings.enableEmailCheckRegex"
style="flex: 1 1 auto;" :placeholder="t('emailCheckRegex')" />
</n-flex>
</n-form-item-row>
</n-form>
</n-card>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
@@ -19,13 +19,14 @@ const props = defineProps({
},
newAddressPath: {
type: Function,
default: async (address_name, domain, cf_token) => {
default: async (address_name, domain, cf_token, enableRandomSubdomain) => {
return await api.fetch("/api/new_address", {
method: "POST",
body: JSON.stringify({
name: address_name,
domain: domain,
cf_token: cf_token,
enableRandomSubdomain: enableRandomSubdomain,
}),
});
},
@@ -47,6 +48,9 @@ const credential = ref('')
const emailName = ref("")
const emailDomain = ref("")
const cfToken = ref("")
const enableRandomSubdomain = ref(false)
const loginCfToken = ref("")
const loginTurnstileRef = ref(null)
const loginMethod = ref('credential') // 'credential' or 'password'
const loginAddress = ref('')
const loginPassword = ref('')
@@ -72,7 +76,8 @@ const login = async () => {
method: 'POST',
body: JSON.stringify({
email: loginAddress.value,
password: await hashPassword(loginPassword.value)
password: await hashPassword(loginPassword.value),
cf_token: loginCfToken.value
})
});
jwt.value = res.jwt;
@@ -85,6 +90,7 @@ const login = async () => {
await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) {
message.error(error.message || "error");
loginTurnstileRef.value?.refresh?.();
}
return;
}
@@ -93,6 +99,13 @@ const login = async () => {
return;
}
try {
await api.fetch('/open_api/credential_login', {
method: 'POST',
body: JSON.stringify({
credential: credential.value,
cf_token: loginCfToken.value
})
});
jwt.value = credential.value;
await api.getSettings();
try {
@@ -103,6 +116,7 @@ const login = async () => {
await router.push(getRouterPathWithLang("/", locale.value));
} catch (error) {
message.error(error.message || "error");
loginTurnstileRef.value?.refresh?.();
}
}
@@ -129,6 +143,8 @@ const { locale, t } = useI18n({
email: 'Email',
password: 'Password',
emailPasswordRequired: 'Email and password are required',
enableRandomSubdomain: 'Use Random Subdomain',
randomSubdomainTip: 'When enabled, the created address will use a random subdomain. Subdomain addresses are recommended for receiving only.',
},
zh: {
login: '登录',
@@ -151,6 +167,8 @@ const { locale, t } = useI18n({
email: '邮箱',
password: '密码',
emailPasswordRequired: '邮箱和密码不能为空',
enableRandomSubdomain: '启用随机子域名',
randomSubdomainTip: '启用后,创建出来的地址会自动挂在随机子域名下。子域名地址更建议仅用于收件。',
}
}
});
@@ -203,7 +221,8 @@ const newEmail = async () => {
const res = await props.newAddressPath(
nameToSend,
emailDomain.value,
cfToken.value
cfToken.value,
enableRandomSubdomain.value
);
jwt.value = res["jwt"];
addressPassword.value = res["password"] || '';
@@ -229,6 +248,19 @@ const addressPrefix = computed(() => {
return openSettings.value.prefix;
});
const canUseRandomSubdomain = computed(() => {
if (!emailDomain.value) {
return false;
}
return (openSettings.value.randomSubdomainDomains || []).includes(emailDomain.value);
});
watch(canUseRandomSubdomain, (enabled) => {
if (!enabled) {
enableRandomSubdomain.value = false;
}
});
const domainsOptions = computed(() => {
// if user has role, return role domains
if (userSettings.value.user_role) {
@@ -289,6 +321,9 @@ onMounted(async () => {
</n-form-item-row>
</div>
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck"
v-model:value="loginCfToken" />
<div class="switch-login-button">
<n-button v-if="openSettings?.enableAddressPassword"
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
@@ -335,6 +370,14 @@ onMounted(async () => {
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="domainsOptions" />
</n-input-group>
<n-form-item-row v-if="canUseRandomSubdomain">
<n-checkbox v-model:checked="enableRandomSubdomain">
{{ t('enableRandomSubdomain') }}
</n-checkbox>
<p style="margin: 8px 0 0; opacity: 0.75;">
{{ t('randomSubdomainTip') }}
</p>
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
<template #icon>

View File

@@ -21,7 +21,8 @@ const { t } = useI18n({
en: {
success: 'Success',
settings: 'Settings',
sourcePrefix: 'Source Mail Prefix',
sourcePrefix: 'Sender Filter',
sourcePrefixPlaceholder: 'Empty=all, prefix match, or /regex/',
name: 'Name',
enableAutoReply: 'Enable Auto Reply',
subject: 'Subject',
@@ -31,7 +32,8 @@ const { t } = useI18n({
zh: {
success: '成功',
settings: '设置',
sourcePrefix: '来源邮件前缀',
sourcePrefix: '发件人过滤',
sourcePrefixPlaceholder: '留空=全部匹配,前缀匹配,或 /正则/',
name: '名称',
enableAutoReply: '启用自动回复',
subject: '主题',
@@ -93,7 +95,8 @@ onMounted(async () => {
<n-input :disabled="!enableAutoReply" v-model:value="name" />
</n-form-item>
<n-form-item :label="t('sourcePrefix')" label-placement="left">
<n-input :disabled="!enableAutoReply" v-model:value="sourcePrefix" />
<n-input :disabled="!enableAutoReply" v-model:value="sourcePrefix"
:placeholder="t('sourcePrefixPlaceholder')" />
</n-form-item>
<n-form-item :label="t('subject')" label-placement="left">
<n-input :disabled="!enableAutoReply" v-model:value="subject" />

View File

@@ -52,13 +52,19 @@ const fetchData = async () => {
}
}
const newAddressPath = async (address_name: string, domain: string, cf_token: string) => {
const newAddressPath = async (
address_name: string,
domain: string,
cf_token: string,
enableRandomSubdomain: boolean
) => {
return await api.fetch("/telegram/new_address", {
method: "POST",
body: JSON.stringify({
initData: telegramApp.value.initData,
address: `${address_name}@${domain}`,
cf_token: cf_token,
enableRandomSubdomain,
}),
});
}

View File

@@ -69,7 +69,12 @@ const user = ref({
password: "",
code: ""
});
const cfToken = ref("")
const signupCfToken = ref("")
const resetCfToken = ref("")
const loginCfToken = ref("")
const signupTurnstileRef = ref(null)
const resetTurnstileRef = ref(null)
const loginTurnstileRef = ref(null)
const emailLogin = async () => {
if (!user.value.email || !user.value.password) {
@@ -82,13 +87,15 @@ const emailLogin = async () => {
body: JSON.stringify({
email: user.value.email,
// hash password
password: await hashPassword(user.value.password)
password: await hashPassword(user.value.password),
cf_token: loginCfToken.value
})
});
userJwt.value = res.jwt;
location.reload();
} catch (error) {
message.error(error.message || "login failed");
loginTurnstileRef.value?.refresh?.();
}
};
@@ -105,7 +112,8 @@ const sendVerificationCode = async () => {
message.error(t('pleaseInputEmail'));
return;
}
if (openSettings.value.cfTurnstileSiteKey && !cfToken.value && userOpenSettings.value.enableMailVerify) {
const currentCfToken = showModal.value ? resetCfToken.value : signupCfToken.value;
if (openSettings.value.cfTurnstileSiteKey && !currentCfToken && userOpenSettings.value.enableMailVerify) {
message.error(t('pleaseCompleteTurnstile'));
return;
}
@@ -114,7 +122,7 @@ const sendVerificationCode = async () => {
method: "POST",
body: JSON.stringify({
email: user.value.email,
cf_token: cfToken.value
cf_token: currentCfToken
})
});
if (res && res.expirationTtl) {
@@ -131,6 +139,11 @@ const sendVerificationCode = async () => {
} catch (error) {
message.error(error.message || "send verification code failed");
}
if (showModal.value) {
resetTurnstileRef.value?.refresh?.();
} else {
signupTurnstileRef.value?.refresh?.();
}
};
const emailSignup = async () => {
@@ -149,7 +162,8 @@ const emailSignup = async () => {
email: user.value.email,
// hash password
password: await hashPassword(user.value.password),
code: user.value.code
code: user.value.code,
cf_token: showModal.value ? resetCfToken.value : signupCfToken.value
}),
message: message
});
@@ -171,7 +185,7 @@ const passkeyLogin = async () => {
domain: location.hostname,
})
})
const credential = await startAuthentication(options)
const credential = await startAuthentication({ optionsJSON: options })
// Send the result to the server and return the promise.
const res = await api.fetch(`/user_api/passkey/authenticate_response`, {
@@ -218,6 +232,7 @@ onMounted(async () => {
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile ref="loginTurnstileRef" v-if="openSettings.enableGlobalTurnstileCheck" v-model:value="loginCfToken" />
<n-button @click="emailLogin" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
@@ -248,7 +263,7 @@ onMounted(async () => {
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-if="userOpenSettings.enableMailVerify" v-model:value="cfToken" />
<Turnstile ref="signupTurnstileRef" v-if="userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
<n-form-item-row v-if="userOpenSettings.enableMailVerify" :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />
@@ -259,6 +274,7 @@ onMounted(async () => {
</n-button>
</n-input-group>
</n-form-item-row>
<Turnstile ref="signupTurnstileRef" v-if="!userOpenSettings.enableMailVerify" v-model:value="signupCfToken" />
</n-form>
<n-button @click="emailSignup" type="primary" block secondary strong>
{{ t('register') }}
@@ -273,7 +289,7 @@ onMounted(async () => {
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="user.password" type="password" show-password-on="click" />
</n-form-item-row>
<Turnstile v-model:value="cfToken" />
<Turnstile ref="resetTurnstileRef" v-model:value="resetCfToken" />
<n-form-item-row :label="t('verifyCode')" required>
<n-input-group>
<n-input v-model:value="user.code" />

View File

@@ -19,28 +19,30 @@ const { t } = useI18n({
en: {
logging: 'Logging in...',
stateNotMatch: 'state not match',
codeNotFound: 'code not found',
},
zh: {
logging: '登录中...',
stateNotMatch: 'state 不匹配',
codeNotFound: '未找到授权码',
}
}
});
onMounted(async () => {
const state = route.query.state;
if (state != userOauth2SessionState.value) {
console.error('state not match');
message.error(t('stateNotMatch'));
return;
}
const code = route.query.code;
if (!code) {
console.error('code not found');
message.error('code not found');
return;
}
try {
const state = route.query.state;
if (state != userOauth2SessionState.value) {
console.error('state not match');
message.error(t('stateNotMatch'));
return;
}
const code = route.query.code;
if (!code) {
console.error('code not found');
message.error(t('codeNotFound'));
return;
}
const res = await api.fetch(`/user_api/oauth2/callback`, {
method: 'POST',
body: JSON.stringify({
@@ -53,6 +55,9 @@ onMounted(async () => {
} catch (error) {
console.error(error);
message.error(error.message || 'error');
} finally {
userOauth2SessionState.value = '';
userOauth2SessionClientID.value = '';
}
});
</script>

View File

@@ -71,7 +71,7 @@ const createPasskey = async () => {
domain: location.hostname,
})
})
const credential = await startRegistration(options)
const credential = await startRegistration({ optionsJSON: options })
// Send the result to the server and return the promise.
await api.fetch(`/user_api/passkey/register_response`, {

View File

@@ -1,6 +1,6 @@
[package]
name = "mail-parser-wasm"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
description = "A simple mail parser for wasm"
license = "MIT"

View File

@@ -101,38 +101,29 @@ impl MessageResult {
pub fn parse_attachment(message: &mail_parser::Message) -> Vec<AttachmentResult> {
let mut attachments: Vec<AttachmentResult> = Vec::new();
for attachment in message.attachments() {
if !attachment.is_message() {
attachments.push(AttachmentResult {
content_id: attachment
.content_id()
.map(|id| id.to_owned())
.unwrap_or(String::new()),
content_type: attachment
.content_type()
.map(|ct| {
let c_type = ct.c_type.clone().into_owned();
let c_subtype = ct.c_subtype.clone();
if c_subtype.is_none() {
return c_type;
} else {
return format!("{}/{}", c_type, c_subtype.unwrap());
}
})
.unwrap_or(String::new()),
filename: attachment
.attachment_name()
.map(|name| name.to_owned())
.unwrap_or(String::new()),
content: attachment.contents().to_vec(),
});
} else {
attachments.append(
&mut attachment
.message()
.map(|msg| parse_attachment(msg))
.unwrap_or(Vec::new()),
);
}
attachments.push(AttachmentResult {
content_id: attachment
.content_id()
.map(|id| id.to_owned())
.unwrap_or(String::new()),
content_type: attachment
.content_type()
.map(|ct| {
let c_type = ct.c_type.clone().into_owned();
let c_subtype = ct.c_subtype.clone();
if c_subtype.is_none() {
return c_type;
} else {
return format!("{}/{}", c_type, c_subtype.unwrap());
}
})
.unwrap_or(String::new()),
filename: attachment
.attachment_name()
.map(|name| name.to_owned())
.unwrap_or(String::new()),
content: attachment.contents().to_vec(),
});
}
attachments
}

5
mail-parser-wasm/worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
mail_parser_wasm_bg.wasm
mail_parser_wasm_bg.wasm.d.ts
mail_parser_wasm.js
mail_parser_wasm.d.ts
README.md

View File

@@ -7,7 +7,7 @@
"url": "https://github.com/dreamhunter2333/cloudflare_temp_email",
"directory": "mail-parser-wasm"
},
"version": "0.2.1",
"version": "0.2.2",
"license": "MIT",
"files": [
"mail_parser_wasm_bg.wasm",

View File

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

View File

@@ -17,7 +17,7 @@ class BackendClient:
"""
def __init__(self, password: str):
self.password = password
self.password = password.strip()
self._client = httpx.Client(
base_url=settings.proxy_url,
headers={

View File

@@ -10,7 +10,7 @@ from zope.interface import implementer
from config import settings
from imap_http_client import BackendClient
from imap_message import SimpleMessage
from parse_email import generate_email_model, parse_email
from parse_email import generate_email_model, parse_email, clean_raw_headers, fix_mojibake
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
@@ -246,6 +246,8 @@ class SimpleMailbox:
try:
if self.name == "INBOX":
raw = item.get("raw", "")
raw = fix_mojibake(raw)
raw = clean_raw_headers(raw)
email_model = parse_email(raw)
elif self.name == "SENT":
email_model, raw = generate_email_model(item)
@@ -256,7 +258,8 @@ class SimpleMailbox:
self._flags[uid_val] = {r"\Seen"}
flags = self._flags[uid_val]
msg = SimpleMessage(
uid_val, email_model, flags=flags, raw=raw
uid_val, email_model, flags=flags, raw=raw,
created_at=item.get("created_at"),
)
self._cache.put(uid_val, msg)
except Exception as e:

View File

@@ -1,28 +1,72 @@
from io import BytesIO
from datetime import datetime, timezone
from twisted.mail import imap4
from zope.interface import implementer
from models import EmailModel
# Locale-independent English names for IMAP date formatting
_MONTHS = ('', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
_DAYS = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
_CREATED_AT_FMTS = (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%d %H:%M:%S.%f",
)
def parse_created_at(created_at: str) -> datetime | None:
"""Parse created_at string into datetime, returns None on failure."""
for fmt in _CREATED_AT_FMTS:
try:
return datetime.strptime(created_at, fmt)
except ValueError:
continue
return None
def format_imap_date(dt: datetime) -> str:
"""Format datetime as IMAP INTERNALDATE: '21-Mar-2026 13:04:59 +0000'."""
return (f"{dt.day:02d}-{_MONTHS[dt.month]}-{dt.year} "
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
def format_rfc2822_date(dt: datetime) -> str:
"""Format datetime as RFC 2822: 'Thu, 13 Mar 2026 11:15:57 +0000'."""
return (f"{_DAYS[dt.weekday()]}, {dt.day:02d} {_MONTHS[dt.month]} {dt.year} "
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
@implementer(imap4.IMessage, imap4.IMessageFile)
class SimpleMessage:
def __init__(self, uid: int, email_model: EmailModel,
flags: set[str] = None, raw: str = None):
flags: set[str] = None, raw: str = None, created_at: str = None):
self.uid = uid
self.email = email_model
self.subparts = self.email.subparts
self._flags = flags if flags is not None else set()
self._raw = raw
self._created_at = created_at
self._fill_date_header()
def _fill_date_header(self):
"""Fill empty/missing Date header from created_at."""
date_val = self.email.headers.get("Date", "").strip()
if date_val or not self._created_at:
return
dt = parse_created_at(self._created_at)
if dt:
self.email.headers["Date"] = format_rfc2822_date(dt)
def getUID(self):
return self.uid
def getHeaders(self, negate, *names):
# Twisted passes header names as bytes (e.g. b"SUBJECT");
# normalize to lowercase str for comparison.
names_lower = set()
for n in names:
if isinstance(n, bytes):
@@ -47,6 +91,10 @@ class SimpleMessage:
return len(self.subparts) > 0
def getSubPart(self, part):
if not self.subparts:
if part == 0:
return SimpleMessage(self.uid, self.email, flags=self._flags)
raise IndexError(part)
return SimpleMessage(self.uid, self.subparts[part], flags=self._flags)
def getBodyFile(self):
@@ -61,6 +109,10 @@ class SimpleMessage:
return list(self._flags)
def getInternalDate(self):
if self._created_at:
dt = parse_created_at(self._created_at)
if dt:
return format_imap_date(dt)
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
# IMessageFile

View File

@@ -81,13 +81,18 @@ class Account(imap4.MemoryAccount):
"""Custom account that initializes mailbox UID index on select."""
def _emptyMailbox(self, name, id):
"""Ignore CREATE for unknown mailboxes instead of crashing."""
return None
"""Return a dummy mailbox for CREATE requests (e.g. Gmail creating Drafts)."""
_logger.debug("Accepting CREATE request for %s", name)
return SimpleMailbox(name, self._client)
def create(self, pathspec):
"""Silently ignore mailbox creation requests from clients."""
_logger.debug("Ignoring CREATE request for %s", pathspec)
return False
"""Accept CREATE silently without actually creating mailboxes."""
_logger.debug("Ignoring CREATE for %s", pathspec)
return True
def listMailboxes(self, ref, wildcard):
"""Only list INBOX and SENT, ignore client-created mailboxes."""
return [("INBOX", self.mailboxes["INBOX"]), ("SENT", self.mailboxes["SENT"])]
@defer.inlineCallbacks
def select(self, name, readwrite=1):
@@ -110,6 +115,7 @@ class SimpleRealm:
sent = SimpleMailbox("SENT", client)
account = Account(username)
account._client = client
account.mailboxes = {"INBOX": inbox, "SENT": sent}
account.subscriptions = ["INBOX", "SENT"]

View File

@@ -1,18 +1,22 @@
import datetime
import json
import logging
import email
from email.message import Message
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from models import EmailModel
from imap_message import parse_created_at, format_rfc2822_date
import re
_logger = logging.getLogger(__name__)
_logger.setLevel(logging.INFO)
# Matches an empty header value (header name with no value)
_EMPTY_HEADER_RE = re.compile(r'^([A-Za-z][A-Za-z0-9-]*):\s*\r?\n', re.MULTILINE)
def get_email_model(msg: Message):
subparts = [
@@ -22,23 +26,62 @@ def get_email_model(msg: Message):
if msg.is_multipart():
body = ""
else:
raw_body = msg.get_payload(decode=True) or b""
charset = msg.get_content_charset() or "utf-8"
try:
body = raw_body.decode(charset, errors="replace")
except LookupError:
body = raw_body.decode("utf-8", errors="replace")
# Keep body in its original CTE encoding (base64/QP/7bit/8bit)
# so it matches the Content-Transfer-Encoding header.
# The IMAP client will decode CTE itself based on BODYSTRUCTURE.
body = msg.get_payload(decode=False) or ""
return EmailModel(
headers={k: v for k, v in msg.items()},
body=body,
content_type=msg.get_content_type(),
size=len(body) + sum(subpart.size for subpart in subparts),
size=len(body.encode("utf-8") if isinstance(body, str) else body) + sum(subpart.size for subpart in subparts),
subparts=subparts,
)
def clean_raw_headers(raw: str) -> str:
"""Remove empty header lines that break Python email parser.
Some emails (e.g. from Gmail via Cloudflare) have duplicate headers
like 'Content-Type: \\n' (empty) followed by the real Content-Type.
The empty one confuses email.message_from_string().
Applies globally so nested message/rfc822 parts are also cleaned.
"""
return _EMPTY_HEADER_RE.sub('', raw)
def fix_mojibake(raw: str) -> str:
"""Fix UTF-8 mojibake where upstream stored UTF-8 bytes as cp1252/latin-1.
Tries whole-string fix first (fast path). If that fails (e.g. complex
emails with mixed binary/text content), falls back to line-by-line fix.
"""
# Fast path: fix entire string at once
for enc in ("cp1252", "latin-1"):
try:
return raw.encode(enc).decode("utf-8")
except (UnicodeDecodeError, UnicodeEncodeError):
continue
# Slow path: fix line by line (tolerates mixed content)
lines = raw.split('\n')
fixed = []
for line in lines:
fixed_line = line
for enc in ("cp1252", "latin-1"):
try:
fixed_line = line.encode(enc).decode("utf-8")
break
except (UnicodeDecodeError, UnicodeEncodeError):
continue
fixed.append(fixed_line)
return '\n'.join(fixed)
def parse_email(raw: str) -> EmailModel:
try:
raw = clean_raw_headers(raw)
msg = email.message_from_string(raw)
return get_email_model(msg)
except Exception as e:
@@ -52,6 +95,7 @@ def parse_email(raw: str) -> EmailModel:
)
def generate_email_model(item: dict) -> tuple[EmailModel, str]:
"""Build an EmailModel from a sendbox item.
@@ -59,25 +103,24 @@ def generate_email_model(item: dict) -> tuple[EmailModel, str]:
synthesised MIME to SimpleMessage for correct BODY[] responses.
"""
email_json = json.loads(item["raw"])
message = MIMEMultipart()
if email_json.get("version") == "v2":
message['From'] = f'{email_json["from_name"]} <{item["address"]}>' if email_json.get("from_name") else item["address"]
message['To'] = f'{email_json["to_name"]} <{email_json["to_mail"]}>' if email_json.get("to_name") else email_json["to_mail"]
message.attach(MIMEText(
email_json["content"],
"html" if email_json.get("is_html") else "plain"
))
from_addr = f'{email_json["from_name"]} <{item["address"]}>' if email_json.get("from_name") else item["address"]
to_addr = f'{email_json["to_name"]} <{email_json["to_mail"]}>' if email_json.get("to_name") else email_json["to_mail"]
content = email_json["content"]
subtype = "html" if email_json.get("is_html") else "plain"
else:
message['From'] = f'{email_json["from"]["name"]} <{email_json["from"]["email"]}>'
message['To'] = ", ".join(
from_addr = f'{email_json["from"]["name"]} <{email_json["from"]["email"]}>'
to_addr = ", ".join(
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
message.attach(MIMEText(
email_json["content"][0]["value"],
"html" if "html" in email_json["content"][0]["type"] else "plain"
))
content = email_json["content"][0]["value"]
subtype = "html" if "html" in email_json["content"][0]["type"] else "plain"
message = MIMEText(content, subtype, "utf-8")
message['From'] = from_addr
message['To'] = to_addr
message['Subject'] = email_json["subject"]
message["Date"] = datetime.datetime.strptime(
item["created_at"], "%Y-%m-%d %H:%M:%S"
).strftime("%a, %d %b %Y %H:%M:%S +0000")
dt = parse_created_at(item["created_at"])
if dt:
message["Date"] = format_rfc2822_date(dt)
raw_mime = message.as_string()
return parse_email(raw_mime), raw_mime

View File

@@ -2,5 +2,5 @@ aiosmtpd==1.4.6
pydantic-settings==2.13.1
Twisted==25.5.0
httpx==0.28.1
pyOpenSSL==25.3.0
pyOpenSSL==26.0.0
service-identity==24.2.0

View File

@@ -149,19 +149,26 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
items: [
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
{ text: 'Configure SMTP IMAP Proxy', link: 'feature/config-smtp-proxy' },
{ text: 'Send Email API', link: 'feature/send-mail-api' },
{ text: 'View Email API', link: 'feature/mail-api' },
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
{ text: 'Configure Telegram Bot', link: 'feature/telegram' },
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
{ text: 'Configure Webhook', link: 'feature/webhook' },
{ text: 'New Address API', link: 'feature/new-address-api' },
{ text: 'OAuth2 Third-party Login', link: 'feature/user-oauth2' },
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
]
},
{
text: 'API Endpoints',
collapsed: false,
items: [
{ text: 'New Address API', link: 'feature/new-address-api' },
{ text: 'View Email API', link: 'feature/mail-api' },
{ text: 'Send Email API', link: 'feature/send-mail-api' },
{ text: 'Delete Address API', link: 'feature/delete-address' },
]
},
{
text: 'Feature Overview',
collapsed: false,

View File

@@ -149,19 +149,26 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
items: [
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
{ text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
{ text: '配置 Telegram Bot', link: 'feature/telegram' },
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
{ text: '配置 webhook', link: 'feature/webhook' },
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
{ text: 'Oauth2 第三方登录', link: 'feature/user-oauth2' },
{ text: '配置其他worker增强', link: 'feature/another-worker-enhanced' },
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
]
},
{
text: 'API 接口',
collapsed: false,
items: [
{ text: '新建邮箱地址 API', link: 'feature/new-address-api' },
{ text: '查看邮件 API', link: 'feature/mail-api' },
{ text: '发送邮件 API', link: 'feature/send-mail-api' },
{ text: '删除邮箱地址 API', link: 'feature/delete-address' },
]
},
{
text: '功能简介',
collapsed: false,

View File

@@ -0,0 +1,42 @@
# Delete Address API
## Admin Delete Address API
Delete an address by address ID. This endpoint requires admin auth and deletes related data (mails, sender settings, bindings, etc.).
```bash
DELETE /admin/delete_address/:id
```
Header:
- `x-admin-auth: <admin_password>`
Example response:
```json
{ "success": true }
```
## User Delete Address API
Delete mailbox by address JWT. The request needs address token permission and deletes related data (received mails, sent items, auto reply data, sender bindings, user bindings, telegram bind records).
```bash
DELETE /api/delete_address
```
Headers:
- `Authorization: Bearer <address_jwt>`
Notes:
- `ENABLE_USER_DELETE_EMAIL` must be enabled.
- Address credential can be obtained from `/api/new_address` or `/admin/new_address`.
Example response:
```json
{ "success": true }
```

View File

@@ -17,6 +17,8 @@ res = requests.get(
)
```
**Note**: `/api/mails` returns raw RFC822 data by design (for example `source`/`raw`), and it does not guarantee parsed fields such as `subject`, `text`, or `html`. Parse the raw source on the client side (for example with `mail-parser-wasm` or `postal-mime`) if you need readable message content.
## Admin Mail API
Supports `address` filter
@@ -43,6 +45,8 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**Note**: `/admin/mails` follows the same design as `/api/mails`: it returns stored raw MIME data. If you need readable subject/body, parse the raw content on the client side.
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.
## Admin Delete Mail API
@@ -127,6 +131,14 @@ print(response.json())
## User Mail API
::: warning Note: User JWT vs Address JWT
This endpoint uses **User JWT** (obtained via `/user_api/login` or `/user_api/register`), with `x-user-token` header.
**Do not confuse with Address JWT**:
- Address JWT uses `Authorization: Bearer <jwt>` to access `/api/*` endpoints
- User JWT uses `x-user-token: <jwt>` to access `/user_api/*` endpoints
:::
Supports `address` filter
```python
@@ -151,4 +163,6 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**Note**: `/user_api/mails` also returns raw RFC822 content from storage; parse it in your client to extract `subject`, `text`, and `html`.
**Note**: Keyword filtering has been removed from the backend API. If you need to filter emails by content, please use the frontend filter input in the UI, which filters the currently displayed page.

View File

@@ -1,5 +1,19 @@
# Create New Email Address API
::: warning Note: Address JWT vs User JWT
This page describes **Address JWT**, which is different from **User JWT**:
- **Address JWT**: Returned when creating a mailbox via `/api/new_address` or `/admin/new_address`
- Use `Authorization: Bearer <jwt>` header
- Access `/api/*` endpoints (view mails, delete mails, etc.)
- **User JWT**: Obtained via `/user_api/login` or `/user_api/register`
- Use `x-user-token: <jwt>` header
- Access `/user_api/*` endpoints (user account management)
**Do not confuse these two JWT types!**
:::
## Create Email Address via Admin API
This is a `python` example using the `requests` library to send emails.
@@ -25,6 +39,32 @@ res = requests.post(
print(res.json())
```
### Create a Subdomain Mailbox Address
If your base domain is already configured in `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`, and
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is enabled (it can also be toggled in the admin panel),
the create-address APIs can accept subdomains directly:
```python
res = requests.post(
"https://xxxx.xxxx/admin/new_address",
json={
"enablePrefix": True,
"name": "project001",
"domain": "team.example.com",
},
headers={
'x-admin-auth': "<your_website_admin_password>",
"Content-Type": "application/json"
}
)
```
- If `example.com` is an allowed base domain, `team.example.com` and `dev.team.example.com` can match successfully
- Lookalike domains such as `badexample.com` will **not** be treated as `example.com`
- This is different from `RANDOM_SUBDOMAIN_DOMAINS`: here the caller **explicitly specifies** the subdomain, instead of the system generating a random one
- In the admin panel, this can be set to **Follow Environment Variable / Force Enable / Force Disable**. Choosing **Follow Environment Variable** clears the admin override and returns to env fallback behavior.
## Batch Create Random Username Email Addresses API Example
### Batch Create Email Addresses via Admin API

View File

@@ -9,3 +9,50 @@ Mail channel is no longer supported. The reference below is limited to the recei
Reference
- [Configure Subdomain Email](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
## Create Random Second-level Subdomain Addresses
If your base domain mail routing is already configured, you can also let users create mailbox
addresses with an automatically generated random second-level subdomain, for example:
- Base domain: `abc.com`
- Created address: `name@x7k2p9q1.abc.com`
This is useful for mailbox isolation and reducing repeated hits on the same address.
Add these worker variables:
```toml
RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
RANDOM_SUBDOMAIN_LENGTH = 8
```
- `RANDOM_SUBDOMAIN_DOMAINS`: base domains that allow optional random second-level subdomains
- `RANDOM_SUBDOMAIN_LENGTH`: random string length, range `1-63`, default `8`
> [!NOTE]
> This feature only appends a random second-level subdomain when the mailbox is created.
>
> It does not automatically create Cloudflare-side subdomain mail routes or DNS records for you,
> so make sure the base-domain/subdomain routing is already available first.
## Let APIs Specify Subdomains Directly
If you do not want the system to generate a random subdomain, and instead want the caller to
explicitly create addresses like `team.abc.com`, enable:
```toml
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
```
When this is enabled, as long as `abc.com` is in the allowed base-domain list, the following
addresses can be created through `/api/new_address` or `/admin/new_address`:
- `name@team.abc.com`
- `name@dev.team.abc.com`
> [!NOTE]
> This only relaxes the domain validation used by the create-address APIs. It does not change the
> default domain dropdown, and it does not create Cloudflare-side subdomain mail routes for you.
>
> If the admin panel has already saved an override once, you can switch it back to **Follow Environment Variable** to clear the override and return to env fallback behavior.

View File

@@ -55,6 +55,42 @@ You need to configure `TG_ALLOW_USER_LANG = true` in worker variables to enable
Language preferences are saved to KV, and each user can set their preference independently.
## Per-User Mail Push
Telegram Bot supports **per-user push notifications**. After a user binds an address, emails received at that address are automatically pushed to the corresponding user.
### User Workflow
1. Find your deployed Bot in Telegram
2. Use `/new [name@domain]` to create a new address, or `/bind <credential>` to bind an existing address
3. Once bound, you will **automatically receive push notifications** when the address receives mail
4. Use `/address` to view your bound addresses
5. Use `/unbind <address>` to unbind an address
> [!TIP]
> Each user can bind up to `TG_MAX_ADDRESS` (default 5) addresses
### Global Push
Admins can enable **global mail push** in the admin panel under `Settings` -> `Telegram`, pushing all emails to a specified list of Telegram user IDs.
- `enableGlobalMailPush`: Enable global push
- `globalMailPushList`: List of Telegram user IDs to receive global push
> [!NOTE]
> Global push and per-user push can work simultaneously. If an address is bound to a user who is also in the global push list, they will receive two notifications.
### Attachment Push
> [!NOTE]
> This feature is available since v1.5.0
Set `ENABLE_TG_PUSH_ATTACHMENT = true` to enable sending email attachments via Telegram push.
- Single file size limit is 50MB (Telegram Bot API limit), oversized attachments are skipped
- Multiple attachments are sent in batches via `sendMediaGroup`, up to 6 per batch
- The first attachment includes the sender and subject as caption
## Mini App
Can be deployed via command line or UI interface

View File

@@ -26,6 +26,77 @@ This project uses [songquanpeng/message-pusher](https://github.com/songquanpeng/
![telegram](/feature/address-webhook.png)
## Webhook Template Examples
### Telegram Bot Push
Push email notifications by calling the Telegram Bot API directly via webhook. Suitable for scenarios where you don't want to deploy the full Telegram Bot integration or need a custom push format.
- **URL**: `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage`
- **Method**: `POST`
- **Headers**:
```json
{
"Content-Type": "application/json"
}
```
- **Body**:
```json
{
"chat_id": "YOUR_CHAT_ID",
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
```
> [!TIP]
> To get your `chat_id`: send a message to the Bot, then visit `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates` and look for the `chat.id` field in the response
### WeChat Work Bot Push
- **URL**: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY`
- **Method**: `POST`
- **Headers**:
```json
{
"Content-Type": "application/json"
}
```
- **Body**:
```json
{
"msgtype": "text",
"text": {
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
}
```
### Discord Webhook Push
- **URL**: `https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN`
- **Method**: `POST`
- **Headers**:
```json
{
"Content-Type": "application/json"
}
```
- **Body**:
```json
{
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
```
## Webhook Data Format
To get the url, you need to configure the worker's `FRONTEND_URL` to your frontend address, or you can construct the url yourself using `id` = `${FRONTEND_URL}?mail_id=${id}`

View File

@@ -32,11 +32,33 @@
| `ADDRESS_REGEX` | Text | Regular expression to replace illegal symbols in `email address` name, symbols not in the regex will be replaced. Default is `[^a-z0-9]` if not set. Use with caution as some symbols may prevent email reception | `[^a-z0-9]` |
| `DEFAULT_DOMAINS` | JSON | Default domains available to users (not logged in or users without assigned roles) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | Text/JSON | Whether to prioritize default domain when creating new addresses, if set to true, will use the first domain when no domain is specified, mainly for telegram bot scenarios | `false` |
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | Text/JSON | Whether to allow create-address APIs to use base-domain suffix matching. When enabled, if `example.com` is allowed, `/api/new_address` and `/admin/new_address` can also accept `foo.example.com` or `a.b.example.com` | `true` |
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | Base domains that allow optional random subdomain creation, so `name@abc.com` can become `name@<random>.abc.com` | `["abc.com"]` |
| `RANDOM_SUBDOMAIN_LENGTH` | Number | Random subdomain length, default `8`, valid range `1-63` | `8` |
| `DOMAIN_LABELS` | JSON | For Chinese domains, you can use DOMAIN_LABELS to display Chinese names | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies | `true` |
| `ENABLE_AUTO_REPLY` | Text/JSON | Allow automatic email replies. Sender filter (`source_prefix`) supports three modes: empty to match all senders, prefix for `startsWith` matching, or `/regex/` syntax for regex matching (e.g. `/@example\.com$/`) | `true` |
| `DEFAULT_SEND_BALANCE` | Text/JSON | Default email sending balance, will be 0 if not set | `1` |
| `ENABLE_ADDRESS_PASSWORD` | Text/JSON | Enable address password feature, when enabled, passwords will be auto-generated for new addresses, supports password login and modification | `true` |
> [!NOTE]
> `RANDOM_SUBDOMAIN_DOMAINS` only controls automatic random subdomain generation during mailbox
> creation. It does not create Cloudflare-side subdomain routing for you.
>
> Subdomain addresses are usually best used for receiving only; for sending, prefer the main
> domain.
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` is different from random subdomain generation: it lets
> API callers **directly specify** a subdomain such as `foo.example.com`, while random subdomain
> generation appends one automatically during creation.
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` precedence: if the env is explicitly set to `false`, the
> feature is globally forced off; otherwise the persisted admin setting takes precedence, and the env
> value is only used as a fallback when no admin setting has been saved.
>
> The admin panel exposes three explicit states: **Follow Environment Variable**, **Force Enable**,
> and **Force Disable**. Saving **Follow Environment Variable** clears the admin override and returns
> the feature to the "unset" fallback behavior.
## Email Reception Related Variables
| Variable Name | Type | Description | Example |
@@ -48,8 +70,11 @@
| `FORWARD_ADDRESS_LIST` | JSON | Global forward address list, disabled if not configured, all emails will be forwarded to listed addresses when enabled | `["xxx@xxx.com"]` |
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | Text/JSON | If attachment exceeds 2MB, remove it, email may lose some information due to parsing | `true` |
| `REMOVE_ALL_ATTACHMENT` | Text/JSON | Remove all attachments, email may lose some information due to parsing | `true` |
| `ENABLE_MAIL_GZIP` | Text/JSON | When enabled, new emails are gzip-compressed and stored in `raw_blob` column to save D1 database space. Existing plaintext `raw` data is automatically compatible for reading. **Run database migration first (`Admin -> Quick Setup -> Database -> Migrate Database` or `POST /admin/db_migration`) to ensure the `raw_blob` column exists before enabling. This feature adds compression/decompression CPU overhead, so enabling it on a paid Cloudflare Worker plan is recommended.** | `true` |
> [!NOTE]
> `ENABLE_MAIL_GZIP` adds CPU cost for gzip compression on write and decompression on read. Free-tier Workers are more likely to hit CPU limits, so a paid plan is recommended before enabling it
>
> `Junk mail checking` and `attachment removal` require email parsing, free tier CPU is limited, may cause large email parsing timeout
>
> If you want stronger email parsing capabilities
@@ -101,8 +126,9 @@
| `ADMIN_CONTACT` | Text | Admin contact information, can be any string, hidden if not configured | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | Text/JSON | Whether to show GitHub link | `true` |
| `STATUS_URL` | Text | Status monitoring page URL, shows Status menu button when configured | `https://status.example.com` |
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration | `xxx` |
| `CF_TURNSTILE_SITE_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | Text/Secret | Turnstile CAPTCHA configuration (for new address creation, registration code, etc.) | `xxx` |
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | Text/JSON | Enable global Turnstile CAPTCHA for all login forms (admin login, user login, address password login), requires Turnstile keys above | `true` |
## Telegram Bot Related Variables
@@ -111,6 +137,7 @@
| `TG_MAX_ADDRESS` | Number | Maximum number of mailboxes that can be bound to telegram bot | `5` |
| `TG_BOT_INFO` | Text | Optional, telegram BOT_INFO, predefined BOT_INFO can reduce webhook latency | `{}` |
| `TG_ALLOW_USER_LANG` | Text/JSON | Allow users to switch language via `/lang` command, default `false` | `true` |
| `ENABLE_TG_PUSH_ATTACHMENT` | Boolean | Enable sending email attachments via Telegram push, default `false`, 50MB per file limit | `true` |
> [!NOTE]
> Telegram functionality requires email parsing, free tier CPU is limited, may cause large email parsing timeout

View File

@@ -0,0 +1,42 @@
# 删除邮箱地址 API
## 管理员删除地址 API
使用地址 ID 删除邮箱地址。该接口需要管理员鉴权,并会同时清理关联数据(收件、发件来源授权、用户绑定等)。
```bash
DELETE /admin/delete_address/:id
```
请求头:
- `x-admin-auth: <admin_password>`
返回示例:
```json
{ "success": true }
```
## 普通地址删除 API
使用地址 JWT 删除当前邮箱。该接口会清理关联数据收件、发件、自动回复、sender 绑定、用户绑定、Telegram 绑定等)。
```bash
DELETE /api/delete_address
```
请求头:
- `Authorization: Bearer <address_jwt>`
说明:
- 需开启 `ENABLE_USER_DELETE_EMAIL = true`
- 地址凭证来自 `/api/new_address``/admin/new_address`
返回示例:
```json
{ "success": true }
```

View File

@@ -17,6 +17,8 @@ res = requests.get(
)
```
**注意**`/api/mails` 按设计返回的是原始 RFC822 数据(如 `source`/`raw`),不保证直接包含 `subject``text``html` 等已解析字段。若要直接读取正文,请在客户端侧解析 `raw`(例如 `mail-parser-wasm``postal-mime`)。
## admin 邮件 API
支持 `address` 过滤
@@ -43,6 +45,8 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**注意**`/admin/mails``/api/mails` 一致,返回的是邮件数据库中的 raw MIME 内容;如需正文/主题等可读字段,请在客户端自行解析 `raw`
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。
## admin 删除邮件 API
@@ -127,6 +131,14 @@ print(response.json())
## user 邮件 API
::: warning 注意:用户 JWT vs 地址 JWT
此接口使用**用户 JWT**(通过 `/user_api/login``/user_api/register` 获得),使用 `x-user-token` header。
**请勿与地址 JWT 混淆**
- 地址 JWT 使用 `Authorization: Bearer <jwt>` 访问 `/api/*` 接口
- 用户 JWT 使用 `x-user-token: <jwt>` 访问 `/user_api/*` 接口
:::
支持 `address` 过滤
```python
@@ -151,4 +163,6 @@ response = requests.get(url, headers=headers, params=querystring)
print(response.json())
```
**注意**`/user_api/mails` 同样返回原始 RFC822 内容;请在客户端解析后提取 `subject``text``html`
**注意**:后端 API 已移除关键词过滤功能。如需按内容过滤邮件,请使用前端界面的过滤输入框,该功能可过滤当前显示的页面。

View File

@@ -1,5 +1,19 @@
# 新建邮箱地址 API
::: warning 注意:地址 JWT vs 用户 JWT
本页面介绍的是**地址 JWT**,与**用户 JWT** 是两种不同的认证方式:
- **地址 JWT**:通过 `/api/new_address``/admin/new_address` 创建邮箱时返回
- 使用 `Authorization: Bearer <jwt>` header
- 用于访问 `/api/*` 接口(查看邮件、删除邮件等)
- **用户 JWT**:通过 `/user_api/login``/user_api/register` 获得
- 使用 `x-user-token: <jwt>` header
- 用于访问 `/user_api/*` 接口(用户账户管理)
**请勿混淆两种 JWT 的使用方式!**
:::
## 通过 admin API 新建邮箱地址
这是一个 `python` 的例子,使用 `requests` 库发送邮件。
@@ -25,6 +39,31 @@ res = requests.post(
print(res.json())
```
### 创建子域名邮箱地址
如果你已经把基础域名配置进 `DOMAINS` / `DEFAULT_DOMAINS` / `USER_ROLES`,并且开启了
`ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH`(管理后台也可单独开关),那么创建地址 API 可以直接接收子域名:
```python
res = requests.post(
"https://xxxx.xxxx/admin/new_address",
json={
"enablePrefix": True,
"name": "project001",
"domain": "team.example.com",
},
headers={
'x-admin-auth': "<你的网站admin密码>",
"Content-Type": "application/json"
}
)
```
- 如果允许域名里有 `example.com`,则 `team.example.com``dev.team.example.com` 都可以匹配成功
- `badexample.com` 这种**不是点分后缀**的域名不会被误判为 `example.com`
- 这与 `RANDOM_SUBDOMAIN_DOMAINS` 不同:这里是**由调用方显式指定子域名**,不是系统自动生成随机子域名
- 管理后台可以把该能力设置为“跟随环境变量 / 强制开启 / 强制关闭”;其中“跟随环境变量”会清空后台覆盖,恢复到未设置后按 env 回退
## 批量创建随机用户名邮箱地址 API 示例
### 通过 admin API 批量新建邮箱地址

View File

@@ -9,3 +9,49 @@ mail channel 已不被支持,下面参考中仅限收件部分。
参考
- [配置子域名邮箱](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)
## 创建随机二级域名地址
如果你已经配置好了基础域名的收件路由,还可以让用户在创建邮箱时,自动生成随机二级域名地址,例如:
- 基础域名:`abc.com`
- 创建结果:`name@x7k2p9q1.abc.com`
这适合做收件隔离、降低地址被重复命中的概率。
`worker` 变量中增加:
```toml
RANDOM_SUBDOMAIN_DOMAINS = ["abc.com"]
RANDOM_SUBDOMAIN_LENGTH = 8
```
- `RANDOM_SUBDOMAIN_DOMAINS`:允许启用随机二级域名的基础域名列表
- `RANDOM_SUBDOMAIN_LENGTH`:随机串长度,范围 `1-63`,默认 `8`
> [!NOTE]
> 这个功能只是在“创建地址”时自动补一个随机二级域名。
>
> 它不会自动帮你创建 Cloudflare 侧的子域名收件路由或 DNS 配置,请先确保基础域名/子域名路由本身已经可用。
## 允许 API 直接指定子域名
如果你不想让系统随机生成子域名,而是希望调用方在创建地址时直接指定 `team.abc.com` 这种子域名,
可以开启:
```toml
ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH = true
```
开启后,只要允许域名里包含基础域名 `abc.com`,那么:
- `name@team.abc.com`
- `name@dev.team.abc.com`
都可以通过 `/api/new_address``/admin/new_address` 创建。
> [!NOTE]
> 这个能力只放宽“创建地址 API 的域名校验”,不会改动默认域名下拉,也不会自动创建 Cloudflare 侧的
> 子域名邮箱路由。
>
> 如果你在管理后台里保存过这个开关,后续也可以通过“跟随环境变量”把它恢复到未设置状态,再重新回退到 env 默认值。

View File

@@ -55,6 +55,42 @@ Telegram Bot 支持中英文切换,用户可以通过 `/lang` 命令设置语
语言偏好会保存到 KV 中,每个用户可以独立设置。
## 每用户邮件推送
Telegram Bot 支持 **每用户独立推送**,用户绑定地址后,该地址收到的邮件会自动推送给对应用户。
### 用户操作流程
1. 在 Telegram 中找到你部署的 Bot
2. 使用 `/new [name@domain]` 创建新邮箱地址,或使用 `/bind <credential>` 绑定已有地址
3. 绑定后,该地址收到邮件时会 **自动推送通知给你**
4. 使用 `/address` 查看已绑定的地址列表
5. 使用 `/unbind <address>` 解绑地址
> [!TIP]
> 每个用户最多可绑定 `TG_MAX_ADDRESS`(默认 5个地址
### 全局推送
管理员可以在后台 `设置` -> `Telegram` 页面开启 **全局邮件推送**,将所有邮件推送给指定的 Telegram 用户 ID 列表。
- `enableGlobalMailPush`: 是否开启全局推送
- `globalMailPushList`: 接收全局推送的 Telegram 用户 ID 列表
> [!NOTE]
> 全局推送和每用户推送可以同时生效。如果某地址已绑定用户,同时该用户也在全局推送列表中,则会收到两条通知。
### 附件推送
> [!NOTE]
> 此功能从 v1.5.0 版本开始支持
配置 `ENABLE_TG_PUSH_ATTACHMENT = true` 后,邮件附件会随推送一起发送到 Telegram。
- 单个附件大小限制 50MBTelegram Bot API 限制),超过的附件会被跳过
- 多附件通过 `sendMediaGroup` 批量发送,每批最多 6 个
- 第一个附件会附带邮件发件人和主题信息作为 caption
## Mini App
可以通过命令行部署,或者 UI 界面部署

View File

@@ -26,6 +26,77 @@
![telegram](/feature/address-webhook.png)
## Webhook 模板示例
### Telegram Bot 推送
通过 Webhook 直接调用 Telegram Bot API 推送邮件通知,适合不想部署完整 Telegram Bot 集成或需要自定义推送格式的场景。
- **URL**: `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/sendMessage`
- **Method**: `POST`
- **Headers**:
```json
{
"Content-Type": "application/json"
}
```
- **Body**:
```json
{
"chat_id": "YOUR_CHAT_ID",
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
```
> [!TIP]
> 获取 `chat_id`:向 Bot 发送一条消息,然后访问 `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates` 查看返回结果中的 `chat.id` 字段
### 企业微信机器人推送
- **URL**: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY`
- **Method**: `POST`
- **Headers**:
```json
{
"Content-Type": "application/json"
}
```
- **Body**:
```json
{
"msgtype": "text",
"text": {
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
}
```
### Discord Webhook 推送
- **URL**: `https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN`
- **Method**: `POST`
- **Headers**:
```json
{
"Content-Type": "application/json"
}
```
- **Body**:
```json
{
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
```
## webhook 数据格式
要获取 url 需要配置 worker 的 `FRONTEND_URL` 为你的前端地址,或者你可以通过 `id` 自己拼接 url = `${FRONTEND_URL}?mail_id=${id}`

View File

@@ -32,11 +32,29 @@
| `ADDRESS_REGEX` | 文本 | `邮箱名称` 替换非法符号的正则表达式, 不在其中的符号将被替换,如果不设置,默认为 `[^a-z0-9]`, 需谨慎使用, 有些符号可能导致无法收件 | `[^a-z0-9]` |
| `DEFAULT_DOMAINS` | JSON | 默认用户可用的域名(未登录或未分配角色的用户) | `["awsl.uk", "dreamhunter2333.xyz"]` |
| `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` | 文本/JSON | 创建新地址时是否优先使用默认域名,如果设置为 true当未指定域名时将使用第一个域名, 主要用于 telegram bot 场景 | `false` |
| `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` | 文本/JSON | 是否允许创建邮箱 API 使用“基础域名后缀匹配”。开启后,如果允许域名里有 `example.com`,则 `/api/new_address``/admin/new_address` 可以接受 `foo.example.com``a.b.example.com` 这类子域名 | `true` |
| `RANDOM_SUBDOMAIN_DOMAINS` | JSON | 允许启用随机子域名的基础域名列表,启用后可把 `name@abc.com` 创建成 `name@随机串.abc.com` | `["abc.com"]` |
| `RANDOM_SUBDOMAIN_LENGTH` | 数字 | 随机子域名长度,默认 `8`,范围 `1-63` | `8` |
| `DOMAIN_LABELS` | JSON | 对于中文域名,可以使用 DOMAIN_LABELS 显示域名的中文展示名称 | `["中文.awsl.uk", "dreamhunter2333.xyz"]` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件 | `true` |
| `ENABLE_AUTO_REPLY` | 文本/JSON | 允许自动回复邮件。发件人过滤(`source_prefix`)支持三种模式:留空匹配所有发件人、填写前缀进行 `startsWith` 匹配、使用 `/regex/` 语法进行正则匹配(如 `/@example\.com$/` | `true` |
| `DEFAULT_SEND_BALANCE` | 文本/JSON | 默认发送邮件余额,如果不设置,将为 0 | `1` |
| `ENABLE_ADDRESS_PASSWORD` | 文本/JSON | 启用邮箱地址密码功能,启用后创建新地址时会自动生成密码,并支持密码登录和修改 | `true` |
> [!NOTE]
> `RANDOM_SUBDOMAIN_DOMAINS` 只负责“创建地址时自动补随机子域名”,不会自动帮你创建 Cloudflare
> 侧的子域名路由。
>
> 子域名地址通常更适合收件;如果要发件,仍建议优先使用主域名。
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 与随机子域名功能不同:它允许 API 调用方**直接指定**
> `foo.example.com` 这类子域名;而随机子域名功能是系统在创建时自动补一个随机前缀。
>
> `ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH` 的优先级为:当 env 明确设置为 `false` 时,全局硬禁用;
> 其他情况下优先使用后台持久化设置,后台未设置时再回退到 env 值。
>
> 管理后台提供三种显式状态:**跟随环境变量**、**强制开启**、**强制关闭**。当你选择
> “跟随环境变量”并保存时,会清空后台覆盖,恢复到“未设置”的回退行为。
## 接受邮件相关变量
| 变量名 | 类型 | 说明 | 示例 |
@@ -48,8 +66,11 @@
| `FORWARD_ADDRESS_LIST` | JSON | 全局转发地址列表,如果不配置则不启用,启用后所有邮件都会转发到列表中的地址 | `["xxx@xxx.com"]` |
| `REMOVE_EXCEED_SIZE_ATTACHMENT` | 文本/JSON | 如果附件大小超过 2MB则删除附件邮件可能由于解析而丢失一些信息 | `true` |
| `REMOVE_ALL_ATTACHMENT` | 文本/JSON | 移除所有附件,邮件可能由于解析而丢失一些信息 | `true` |
| `ENABLE_MAIL_GZIP` | 文本/JSON | 启用后新邮件将 Gzip 压缩存储到 `raw_blob` 字段,可节省 D1 数据库空间。已有明文 `raw` 数据自动兼容读取。**启用前请先执行数据库迁移(`Admin -> 快速设置 -> 数据库 -> 升级数据库 Schema``POST /admin/db_migration`),确保 `raw_blob` 列已创建。该功能会增加压缩/解压 CPU 开销,建议使用 Cloudflare Worker 付费 Plan 再开启。** | `true` |
> [!NOTE]
> `ENABLE_MAIL_GZIP` 会增加邮件写入压缩与读取解压的 CPU 消耗,免费版 Worker 更容易触发 CPU 限制,建议付费 Plan 再开启
>
> `垃圾邮件检查` 和 `移除附件功能` 需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时
>
> 如果你想解析邮件能力更强
@@ -101,8 +122,9 @@
| `ADMIN_CONTACT` | 文本 | admin 联系方式,可配置任意字符串, 不配置则不显示 | `xxx@gmail.com` |
| `DISABLE_SHOW_GITHUB` | 文本/JSON | 是否显示 GitHub 链接 | `true` |
| `STATUS_URL` | 文本 | 状态监控页面 URL配置后显示 Status 菜单按钮 | `https://status.example.com` |
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置 | `xxx` |
| `CF_TURNSTILE_SITE_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
| `CF_TURNSTILE_SECRET_KEY` | 文本/Secret | Turnstile 人机验证配置(用于新建邮箱、注册验证码等) | `xxx` |
| `ENABLE_GLOBAL_TURNSTILE_CHECK` | 文本/JSON | 启用全局登录表单的 Turnstile 人机验证(管理员登录、用户登录、邮箱密码登录),需同时配置上述 Turnstile 密钥 | `true` |
## Telegram Bot 相关变量
@@ -111,6 +133,7 @@
| `TG_MAX_ADDRESS` | 数字 | telegram bot 最多绑定邮箱数量 | `5` |
| `TG_BOT_INFO` | 文本 | 可不配置telegram BOT_INFO预定义的 BOT_INFO 可以降低 webhook 的延迟 | `{}` |
| `TG_ALLOW_USER_LANG`| 文本/JSON | 是否允许用户通过 `/lang` 命令切换语言,默认 `false` | `true`|
| `ENABLE_TG_PUSH_ATTACHMENT`| 布尔值 | 是否启用 Telegram 推送邮件附件,默认 `false`,单文件限制 50MB | `true`|
> [!NOTE]
> Telegram 功能需要解析邮件,免费版 CPU 有限,可能会导致大邮件解析超时

View File

@@ -1,12 +1,12 @@
{
"name": "temp-mail-docs",
"private": true,
"version": "1.4.0",
"version": "1.5.0",
"type": "module",
"devDependencies": {
"@types/node": "^25.3.3",
"@types/node": "^25.4.0",
"vitepress": "^1.6.4",
"wrangler": "^4.70.0"
"wrangler": "^4.72.0"
},
"scripts": {
"dev": "vitepress dev docs",

View File

@@ -13,19 +13,19 @@ importers:
version: 3.10.1
devDependencies:
'@types/node':
specifier: ^25.3.3
version: 25.3.3
specifier: ^25.4.0
version: 25.4.0
vitepress:
specifier: ^1.6.4
version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@25.3.3)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5)
version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@25.4.0)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5)
wrangler:
specifier: ^4.70.0
version: 4.70.0
specifier: ^4.72.0
version: 4.72.0
packages:
'@algolia/abtesting@1.15.1':
resolution: {integrity: sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==}
'@algolia/abtesting@1.15.2':
resolution: {integrity: sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA==}
engines: {node: '>= 14.0.0'}
'@algolia/autocomplete-core@1.17.7':
@@ -48,56 +48,56 @@ packages:
'@algolia/client-search': '>= 4.9.1 < 6'
algoliasearch: '>= 4.9.1 < 6'
'@algolia/client-abtesting@5.49.1':
resolution: {integrity: sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==}
'@algolia/client-abtesting@5.49.2':
resolution: {integrity: sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ==}
engines: {node: '>= 14.0.0'}
'@algolia/client-analytics@5.49.1':
resolution: {integrity: sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==}
'@algolia/client-analytics@5.49.2':
resolution: {integrity: sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q==}
engines: {node: '>= 14.0.0'}
'@algolia/client-common@5.49.1':
resolution: {integrity: sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==}
'@algolia/client-common@5.49.2':
resolution: {integrity: sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw==}
engines: {node: '>= 14.0.0'}
'@algolia/client-insights@5.49.1':
resolution: {integrity: sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==}
'@algolia/client-insights@5.49.2':
resolution: {integrity: sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA==}
engines: {node: '>= 14.0.0'}
'@algolia/client-personalization@5.49.1':
resolution: {integrity: sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==}
'@algolia/client-personalization@5.49.2':
resolution: {integrity: sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA==}
engines: {node: '>= 14.0.0'}
'@algolia/client-query-suggestions@5.49.1':
resolution: {integrity: sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==}
'@algolia/client-query-suggestions@5.49.2':
resolution: {integrity: sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA==}
engines: {node: '>= 14.0.0'}
'@algolia/client-search@5.49.1':
resolution: {integrity: sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==}
'@algolia/client-search@5.49.2':
resolution: {integrity: sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==}
engines: {node: '>= 14.0.0'}
'@algolia/ingestion@1.49.1':
resolution: {integrity: sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==}
'@algolia/ingestion@1.49.2':
resolution: {integrity: sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw==}
engines: {node: '>= 14.0.0'}
'@algolia/monitoring@1.49.1':
resolution: {integrity: sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==}
'@algolia/monitoring@1.49.2':
resolution: {integrity: sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg==}
engines: {node: '>= 14.0.0'}
'@algolia/recommend@5.49.1':
resolution: {integrity: sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==}
'@algolia/recommend@5.49.2':
resolution: {integrity: sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA==}
engines: {node: '>= 14.0.0'}
'@algolia/requester-browser-xhr@5.49.1':
resolution: {integrity: sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==}
'@algolia/requester-browser-xhr@5.49.2':
resolution: {integrity: sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA==}
engines: {node: '>= 14.0.0'}
'@algolia/requester-fetch@5.49.1':
resolution: {integrity: sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==}
'@algolia/requester-fetch@5.49.2':
resolution: {integrity: sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ==}
engines: {node: '>= 14.0.0'}
'@algolia/requester-node-http@5.49.1':
resolution: {integrity: sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==}
'@algolia/requester-node-http@5.49.2':
resolution: {integrity: sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==}
engines: {node: '>= 14.0.0'}
'@babel/helper-string-parser@7.27.1':
@@ -121,41 +121,41 @@ packages:
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
'@cloudflare/unenv-preset@2.14.0':
resolution: {integrity: sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==}
'@cloudflare/unenv-preset@2.15.0':
resolution: {integrity: sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: ^1.20260218.0
workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260301.1':
resolution: {integrity: sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ==}
'@cloudflare/workerd-darwin-64@1.20260310.1':
resolution: {integrity: sha512-hF2VpoWaMb1fiGCQJqCY6M8I+2QQqjkyY4LiDYdTL5D/w6C1l5v1zhc0/jrjdD1DXfpJtpcSMSmEPjHse4p9Ig==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260301.1':
resolution: {integrity: sha512-PPIetY3e67YBr9O4UhILK8nbm5TqUDl14qx4rwFNrRSBOvlzuczzbd4BqgpAtbGVFxKp1PWpjAnBvGU/OI/tLQ==}
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
resolution: {integrity: sha512-h/Vl3XrYYPI6yFDE27XO1QPq/1G1lKIM8tzZGIWYpntK3IN5XtH3Ee/sLaegpJ49aIJoqhF2mVAZ6Yw+Vk2gJw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260301.1':
resolution: {integrity: sha512-Gu5vaVTZuYl3cHa+u5CDzSVDBvSkfNyuAHi6Mdfut7TTUdcb3V5CIcR/mXRSyMXzEy9YxEWIfdKMxOMBjupvYQ==}
'@cloudflare/workerd-linux-64@1.20260310.1':
resolution: {integrity: sha512-XzQ0GZ8G5P4d74bQYOIP2Su4CLdNPpYidrInaSOuSxMw+HamsHaFrjVsrV2mPy/yk2hi6SY2yMbgKFK9YjA7vw==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260301.1':
resolution: {integrity: sha512-igL1pkyCXW6GiGpjdOAvqMi87UW0LMc/+yIQe/CSzuZJm5GzXoAMrwVTkCFnikk6JVGELrM5x0tGYlxa0sk5Iw==}
'@cloudflare/workerd-linux-arm64@1.20260310.1':
resolution: {integrity: sha512-sxv4CxnN4ZR0uQGTFVGa0V4KTqwdej/czpIc5tYS86G8FQQoGIBiAIs2VvU7b8EROPcandxYHDBPTb+D9HIMPw==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260301.1':
resolution: {integrity: sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A==}
'@cloudflare/workerd-windows-64@1.20260310.1':
resolution: {integrity: sha512-+1ZTViWKJypLfgH/luAHCqkent0DEBjAjvO40iAhOMHRLYP/SPphLvr4Jpi6lb+sIocS8Q1QZL4uM5Etg1Wskg==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
@@ -484,8 +484,8 @@ packages:
cpu: [x64]
os: [win32]
'@iconify-json/simple-icons@1.2.72':
resolution: {integrity: sha512-wkcixntHvaCoqPqerGrNFcHQ3Yx1ux4ZkhscCDK0DEHpP62XCH+cxq1HTsRjbUiQl/M9K8bj03HF6Wgn5iE2rQ==}
'@iconify-json/simple-icons@1.2.73':
resolution: {integrity: sha512-nQZTwul4c2zBqH/aLP4zMOiElj93T6HawbrP+sFQKpxmBdS5x1duCK3cAnkj6dntHz84EYkzaQRM83V2pj4qxA==}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -820,8 +820,8 @@ packages:
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@25.3.3':
resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==}
'@types/node@25.4.0':
resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@@ -839,17 +839,17 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vue/compiler-core@3.5.29':
resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==}
'@vue/compiler-core@3.5.30':
resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==}
'@vue/compiler-dom@3.5.29':
resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==}
'@vue/compiler-dom@3.5.30':
resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==}
'@vue/compiler-sfc@3.5.29':
resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==}
'@vue/compiler-sfc@3.5.30':
resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==}
'@vue/compiler-ssr@3.5.29':
resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==}
'@vue/compiler-ssr@3.5.30':
resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==}
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
@@ -860,22 +860,22 @@ packages:
'@vue/devtools-shared@7.7.9':
resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==}
'@vue/reactivity@3.5.29':
resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==}
'@vue/reactivity@3.5.30':
resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==}
'@vue/runtime-core@3.5.29':
resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==}
'@vue/runtime-core@3.5.30':
resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==}
'@vue/runtime-dom@3.5.29':
resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==}
'@vue/runtime-dom@3.5.30':
resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==}
'@vue/server-renderer@3.5.29':
resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==}
'@vue/server-renderer@3.5.30':
resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==}
peerDependencies:
vue: 3.5.29
vue: 3.5.30
'@vue/shared@3.5.29':
resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
'@vue/shared@3.5.30':
resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==}
'@vueuse/core@12.8.2':
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
@@ -927,8 +927,8 @@ packages:
'@vueuse/shared@12.8.2':
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
algoliasearch@5.49.1:
resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==}
algoliasearch@5.49.2:
resolution: {integrity: sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==}
engines: {node: '>= 14.0.0'}
birpc@2.9.0:
@@ -1064,8 +1064,8 @@ packages:
micromark-util-types@2.0.2:
resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==}
miniflare@4.20260301.1:
resolution: {integrity: sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog==}
miniflare@4.20260310.0:
resolution: {integrity: sha512-uC5vNPenFpDSj5aUU3wGSABG6UUqMr+Xs1m4AkCrTHo37F4Z6xcQw5BXqViTfPDVT/zcYH1UgTVoXhr1l6ZMXw==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -1102,8 +1102,8 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
preact@10.28.4:
resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==}
preact@10.29.0:
resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -1268,25 +1268,25 @@ packages:
postcss:
optional: true
vue@3.5.29:
resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==}
vue@3.5.30:
resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
workerd@1.20260301.1:
resolution: {integrity: sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw==}
workerd@1.20260310.1:
resolution: {integrity: sha512-yawXhypXXHtArikJj15HOMknNGikpBbSg2ZDe6lddUbqZnJXuCVSkgc/0ArUeVMG1jbbGvpst+REFtKwILvRTQ==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.70.0:
resolution: {integrity: sha512-PNDZ9o4e+B5x+1bUbz62Hmwz6G9lw+I9pnYe/AguLddJFjfIyt2cmFOUOb3eOZSoXsrhcEPUg2YidYIbVwUkfw==}
wrangler@4.72.0:
resolution: {integrity: sha512-bKkb8150JGzJZJWiNB2nu/33smVfawmfYiecA6rW4XH7xS23/jqMbgpdelM34W/7a1IhR66qeQGVqTRXROtAZg==}
engines: {node: '>=20.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260226.1
'@cloudflare/workers-types': ^4.20260310.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
@@ -1314,117 +1314,117 @@ packages:
snapshots:
'@algolia/abtesting@1.15.1':
'@algolia/abtesting@1.15.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)':
'@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)':
dependencies:
'@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
'@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
transitivePeerDependencies:
- '@algolia/client-search'
- algoliasearch
- search-insights
'@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)':
'@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)':
dependencies:
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
search-insights: 2.13.0
transitivePeerDependencies:
- '@algolia/client-search'
- algoliasearch
'@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)':
'@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)':
dependencies:
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
'@algolia/client-search': 5.49.1
algoliasearch: 5.49.1
'@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
'@algolia/client-search': 5.49.2
algoliasearch: 5.49.2
'@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)':
'@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)':
dependencies:
'@algolia/client-search': 5.49.1
algoliasearch: 5.49.1
'@algolia/client-search': 5.49.2
algoliasearch: 5.49.2
'@algolia/client-abtesting@5.49.1':
'@algolia/client-abtesting@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/client-analytics@5.49.1':
'@algolia/client-analytics@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/client-common@5.49.1': {}
'@algolia/client-common@5.49.2': {}
'@algolia/client-insights@5.49.1':
'@algolia/client-insights@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/client-personalization@5.49.1':
'@algolia/client-personalization@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/client-query-suggestions@5.49.1':
'@algolia/client-query-suggestions@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/client-search@5.49.1':
'@algolia/client-search@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/ingestion@1.49.1':
'@algolia/ingestion@1.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/monitoring@1.49.1':
'@algolia/monitoring@1.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/recommend@5.49.1':
'@algolia/recommend@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
'@algolia/requester-browser-xhr@5.49.1':
'@algolia/requester-browser-xhr@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-fetch@5.49.1':
'@algolia/requester-fetch@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/client-common': 5.49.2
'@algolia/requester-node-http@5.49.1':
'@algolia/requester-node-http@5.49.2':
dependencies:
'@algolia/client-common': 5.49.1
'@algolia/client-common': 5.49.2
'@babel/helper-string-parser@7.27.1': {}
@@ -1441,25 +1441,25 @@ snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/unenv-preset@2.14.0(unenv@2.0.0-rc.24)(workerd@1.20260301.1)':
'@cloudflare/unenv-preset@2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260301.1
workerd: 1.20260310.1
'@cloudflare/workerd-darwin-64@1.20260301.1':
'@cloudflare/workerd-darwin-64@1.20260310.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260301.1':
'@cloudflare/workerd-darwin-arm64@1.20260310.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260301.1':
'@cloudflare/workerd-linux-64@1.20260310.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260301.1':
'@cloudflare/workerd-linux-arm64@1.20260310.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260301.1':
'@cloudflare/workerd-windows-64@1.20260310.1':
optional: true
'@cspotcode/source-map-support@0.8.1':
@@ -1468,10 +1468,10 @@ snapshots:
'@docsearch/css@3.8.2': {}
'@docsearch/js@3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)':
'@docsearch/js@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)':
dependencies:
'@docsearch/react': 3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)
preact: 10.28.4
'@docsearch/react': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)
preact: 10.29.0
transitivePeerDependencies:
- '@algolia/client-search'
- '@types/react'
@@ -1479,12 +1479,12 @@ snapshots:
- react-dom
- search-insights
'@docsearch/react@3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)':
'@docsearch/react@3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)':
dependencies:
'@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.13.0)
'@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
'@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)(search-insights@2.13.0)
'@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.2)(algoliasearch@5.49.2)
'@docsearch/css': 3.8.2
algoliasearch: 5.49.1
algoliasearch: 5.49.2
optionalDependencies:
search-insights: 2.13.0
transitivePeerDependencies:
@@ -1642,7 +1642,7 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@iconify-json/simple-icons@1.2.72':
'@iconify-json/simple-icons@1.2.73':
dependencies:
'@iconify/types': 2.0.0
@@ -1903,7 +1903,7 @@ snapshots:
'@types/mdurl@2.0.0': {}
'@types/node@25.3.3':
'@types/node@25.4.0':
dependencies:
undici-types: 7.18.2
@@ -1913,40 +1913,40 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.3.3))(vue@3.5.29(typescript@5.4.5))':
'@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.4.0))(vue@3.5.30(typescript@5.4.5))':
dependencies:
vite: 5.4.21(@types/node@25.3.3)
vue: 3.5.29(typescript@5.4.5)
vite: 5.4.21(@types/node@25.4.0)
vue: 3.5.30(typescript@5.4.5)
'@vue/compiler-core@3.5.29':
'@vue/compiler-core@3.5.30':
dependencies:
'@babel/parser': 7.29.0
'@vue/shared': 3.5.29
'@vue/shared': 3.5.30
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.29':
'@vue/compiler-dom@3.5.30':
dependencies:
'@vue/compiler-core': 3.5.29
'@vue/shared': 3.5.29
'@vue/compiler-core': 3.5.30
'@vue/shared': 3.5.30
'@vue/compiler-sfc@3.5.29':
'@vue/compiler-sfc@3.5.30':
dependencies:
'@babel/parser': 7.29.0
'@vue/compiler-core': 3.5.29
'@vue/compiler-dom': 3.5.29
'@vue/compiler-ssr': 3.5.29
'@vue/shared': 3.5.29
'@vue/compiler-core': 3.5.30
'@vue/compiler-dom': 3.5.30
'@vue/compiler-ssr': 3.5.30
'@vue/shared': 3.5.30
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.8
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.29':
'@vue/compiler-ssr@3.5.30':
dependencies:
'@vue/compiler-dom': 3.5.29
'@vue/shared': 3.5.29
'@vue/compiler-dom': 3.5.30
'@vue/shared': 3.5.30
'@vue/devtools-api@7.7.9':
dependencies:
@@ -1966,36 +1966,36 @@ snapshots:
dependencies:
rfdc: 1.4.1
'@vue/reactivity@3.5.29':
'@vue/reactivity@3.5.30':
dependencies:
'@vue/shared': 3.5.29
'@vue/shared': 3.5.30
'@vue/runtime-core@3.5.29':
'@vue/runtime-core@3.5.30':
dependencies:
'@vue/reactivity': 3.5.29
'@vue/shared': 3.5.29
'@vue/reactivity': 3.5.30
'@vue/shared': 3.5.30
'@vue/runtime-dom@3.5.29':
'@vue/runtime-dom@3.5.30':
dependencies:
'@vue/reactivity': 3.5.29
'@vue/runtime-core': 3.5.29
'@vue/shared': 3.5.29
'@vue/reactivity': 3.5.30
'@vue/runtime-core': 3.5.30
'@vue/shared': 3.5.30
csstype: 3.2.3
'@vue/server-renderer@3.5.29(vue@3.5.29(typescript@5.4.5))':
'@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.4.5))':
dependencies:
'@vue/compiler-ssr': 3.5.29
'@vue/shared': 3.5.29
vue: 3.5.29(typescript@5.4.5)
'@vue/compiler-ssr': 3.5.30
'@vue/shared': 3.5.30
vue: 3.5.30(typescript@5.4.5)
'@vue/shared@3.5.29': {}
'@vue/shared@3.5.30': {}
'@vueuse/core@12.8.2(typescript@5.4.5)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 12.8.2
'@vueuse/shared': 12.8.2(typescript@5.4.5)
vue: 3.5.29(typescript@5.4.5)
vue: 3.5.30(typescript@5.4.5)
transitivePeerDependencies:
- typescript
@@ -2003,7 +2003,7 @@ snapshots:
dependencies:
'@vueuse/core': 12.8.2(typescript@5.4.5)
'@vueuse/shared': 12.8.2(typescript@5.4.5)
vue: 3.5.29(typescript@5.4.5)
vue: 3.5.30(typescript@5.4.5)
optionalDependencies:
focus-trap: 7.8.0
transitivePeerDependencies:
@@ -2013,26 +2013,26 @@ snapshots:
'@vueuse/shared@12.8.2(typescript@5.4.5)':
dependencies:
vue: 3.5.29(typescript@5.4.5)
vue: 3.5.30(typescript@5.4.5)
transitivePeerDependencies:
- typescript
algoliasearch@5.49.1:
algoliasearch@5.49.2:
dependencies:
'@algolia/abtesting': 1.15.1
'@algolia/client-abtesting': 5.49.1
'@algolia/client-analytics': 5.49.1
'@algolia/client-common': 5.49.1
'@algolia/client-insights': 5.49.1
'@algolia/client-personalization': 5.49.1
'@algolia/client-query-suggestions': 5.49.1
'@algolia/client-search': 5.49.1
'@algolia/ingestion': 1.49.1
'@algolia/monitoring': 1.49.1
'@algolia/recommend': 5.49.1
'@algolia/requester-browser-xhr': 5.49.1
'@algolia/requester-fetch': 5.49.1
'@algolia/requester-node-http': 5.49.1
'@algolia/abtesting': 1.15.2
'@algolia/client-abtesting': 5.49.2
'@algolia/client-analytics': 5.49.2
'@algolia/client-common': 5.49.2
'@algolia/client-insights': 5.49.2
'@algolia/client-personalization': 5.49.2
'@algolia/client-query-suggestions': 5.49.2
'@algolia/client-search': 5.49.2
'@algolia/ingestion': 1.49.2
'@algolia/monitoring': 1.49.2
'@algolia/recommend': 5.49.2
'@algolia/requester-browser-xhr': 5.49.2
'@algolia/requester-fetch': 5.49.2
'@algolia/requester-node-http': 5.49.2
birpc@2.9.0: {}
@@ -2212,12 +2212,12 @@ snapshots:
micromark-util-types@2.0.2: {}
miniflare@4.20260301.1:
miniflare@4.20260310.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.18.2
workerd: 1.20260301.1
workerd: 1.20260310.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
@@ -2252,7 +2252,7 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact@10.28.4: {}
preact@10.29.0: {}
process-nextick-args@2.0.1: {}
@@ -2435,35 +2435,35 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@5.4.21(@types/node@25.3.3):
vite@5.4.21(@types/node@25.4.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.8
rollup: 4.59.0
optionalDependencies:
'@types/node': 25.3.3
'@types/node': 25.4.0
fsevents: 2.3.3
vitepress@1.6.4(@algolia/client-search@5.49.1)(@types/node@25.3.3)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5):
vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@25.4.0)(postcss@8.5.8)(search-insights@2.13.0)(typescript@5.4.5):
dependencies:
'@docsearch/css': 3.8.2
'@docsearch/js': 3.8.2(@algolia/client-search@5.49.1)(search-insights@2.13.0)
'@iconify-json/simple-icons': 1.2.72
'@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.13.0)
'@iconify-json/simple-icons': 1.2.73
'@shikijs/core': 2.5.0
'@shikijs/transformers': 2.5.0
'@shikijs/types': 2.5.0
'@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.3.3))(vue@3.5.29(typescript@5.4.5))
'@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.4.0))(vue@3.5.30(typescript@5.4.5))
'@vue/devtools-api': 7.7.9
'@vue/shared': 3.5.29
'@vue/shared': 3.5.30
'@vueuse/core': 12.8.2(typescript@5.4.5)
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@5.4.5)
focus-trap: 7.8.0
mark.js: 8.11.1
minisearch: 7.2.0
shiki: 2.5.0
vite: 5.4.21(@types/node@25.3.3)
vue: 3.5.29(typescript@5.4.5)
vite: 5.4.21(@types/node@25.4.0)
vue: 3.5.30(typescript@5.4.5)
optionalDependencies:
postcss: 8.5.8
transitivePeerDependencies:
@@ -2493,34 +2493,34 @@ snapshots:
- typescript
- universal-cookie
vue@3.5.29(typescript@5.4.5):
vue@3.5.30(typescript@5.4.5):
dependencies:
'@vue/compiler-dom': 3.5.29
'@vue/compiler-sfc': 3.5.29
'@vue/runtime-dom': 3.5.29
'@vue/server-renderer': 3.5.29(vue@3.5.29(typescript@5.4.5))
'@vue/shared': 3.5.29
'@vue/compiler-dom': 3.5.30
'@vue/compiler-sfc': 3.5.30
'@vue/runtime-dom': 3.5.30
'@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.4.5))
'@vue/shared': 3.5.30
optionalDependencies:
typescript: 5.4.5
workerd@1.20260301.1:
workerd@1.20260310.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260301.1
'@cloudflare/workerd-darwin-arm64': 1.20260301.1
'@cloudflare/workerd-linux-64': 1.20260301.1
'@cloudflare/workerd-linux-arm64': 1.20260301.1
'@cloudflare/workerd-windows-64': 1.20260301.1
'@cloudflare/workerd-darwin-64': 1.20260310.1
'@cloudflare/workerd-darwin-arm64': 1.20260310.1
'@cloudflare/workerd-linux-64': 1.20260310.1
'@cloudflare/workerd-linux-arm64': 1.20260310.1
'@cloudflare/workerd-windows-64': 1.20260310.1
wrangler@4.70.0:
wrangler@4.72.0:
dependencies:
'@cloudflare/kv-asset-handler': 0.4.2
'@cloudflare/unenv-preset': 2.14.0(unenv@2.0.0-rc.24)(workerd@1.20260301.1)
'@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260310.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260301.1
miniflare: 4.20260310.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260301.1
workerd: 1.20260310.1
optionalDependencies:
fsevents: 2.3.3
transitivePeerDependencies:

View File

@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.4.0",
"version": "1.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -11,20 +11,19 @@
"build": "wrangler deploy --dry-run --outdir dist --minify"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260305.1",
"@cloudflare/workers-types": "^4.20260310.1",
"@eslint/js": "9.39.1",
"@simplewebauthn/types": "10.0.0",
"@types/node": "^25.3.3",
"@types/node": "^25.4.0",
"eslint": "9.39.1",
"globals": "^16.5.0",
"typescript-eslint": "^8.56.1",
"wrangler": "^4.70.0"
"typescript-eslint": "^8.57.0",
"wrangler": "^4.72.0"
},
"dependencies": {
"@aws-sdk/client-s3": "3.888.0",
"@aws-sdk/s3-request-presigner": "3.888.0",
"@simplewebauthn/server": "10.0.1",
"hono": "^4.12.5",
"@simplewebauthn/server": "13.2.3",
"hono": "^4.12.7",
"jsonpath-plus": "^10.4.0",
"mimetext": "^3.0.28",
"postal-mime": "^2.7.3",

520
worker/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { Context } from "hono";
import { handleListQuery } from "../common";
import { handleMailListQuery } from "../common";
export default {
getMails: async (c: Context<HonoCustomType>) => {
@@ -9,7 +9,7 @@ export default {
const filterQuerys = [addressQuery].filter((item) => item).join(" and ");
const finalQuery = filterQuerys.length > 0 ? `where ${filterQuerys}` : "";
const filterParams = [...addressParams]
return await handleListQuery(c,
return await handleMailListQuery(c,
`SELECT * FROM raw_mails ${finalQuery}`,
`SELECT count(*) as count FROM raw_mails ${finalQuery}`,
filterParams, limit, offset
@@ -17,7 +17,7 @@ export default {
},
getUnknowMails: async (c: Context<HonoCustomType>) => {
const { limit, offset } = c.req.query();
return await handleListQuery(c,
return await handleMailListQuery(c,
`SELECT * FROM raw_mails where address NOT IN (select name from address) `,
`SELECT count(*) as count FROM raw_mails`
+ ` where address NOT IN (select name from address) `,

View File

@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
source TEXT,
address TEXT,
raw TEXT,
raw_blob BLOB,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -184,6 +185,18 @@ export default {
// migration to v0.0.6: add message_id index on raw_mails
await c.env.DB.exec(`CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);`);
}
if (version && version <= "v0.0.6") {
// migration to v0.0.7: add raw_blob column for gzip compressed email storage
const tableInfo = await c.env.DB.prepare(
`PRAGMA table_info(raw_mails)`
).all();
const hasRawBlob = tableInfo.results?.some(
(col: any) => col.name === 'raw_blob'
);
if (!hasRawBlob) {
await c.env.DB.exec(`ALTER TABLE raw_mails ADD COLUMN raw_blob BLOB;`);
}
}
if (version != CONSTANTS.DB_VERSION) {
// remove all \r and \n characters from the query string
// split by ; and join with a ;\n

View File

@@ -44,20 +44,19 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
if (!headers.has('Message-ID')) headers.set('Message-ID', `<e2e-${Date.now()}@test>`);
const rawBytes = new TextEncoder().encode(raw);
let rejected: string | undefined;
const state = { rejected: undefined as string | undefined, replyCalled: false };
const mockMessage: ForwardableEmailMessage = {
from, to, headers,
rawSize: rawBytes.byteLength,
raw: new ReadableStream({ start(ctrl) { ctrl.enqueue(rawBytes); ctrl.close(); } }),
setReject(reason: string) { rejected = reason; },
setReject(reason: string) { state.rejected = reason; },
forward: async () => ({ messageId: '' }),
reply: async () => ({ messageId: '' }),
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
};
const { email: emailHandler } = await import('../email');
await emailHandler(mockMessage, c.env, { waitUntil: () => {}, passThroughOnException: () => {} });
return c.json({ success: !rejected, ...(rejected ? { rejected } : {}) });
return c.json({ success: !state.rejected, replyCalled: state.replyCalled, ...(state.rejected ? { rejected: state.rejected } : {}) });
};
export default { seedMail, receiveMail };

View File

@@ -3,7 +3,7 @@ import { Jwt } from 'hono/utils/jwt'
import i18n from '../i18n'
import { sendAdminInternalMail, getJsonSetting, saveSetting, getUserRoles, getBooleanValue, hashPassword } from '../utils'
import { newAddress, handleListQuery } from '../common'
import { newAddress, handleListQuery, getAddressCreationSettings, getAddressCreationSubdomainMatchStatus } from '../common'
import { CONSTANTS } from '../constants'
import cleanup_api from './cleanup_api'
import admin_user_api from './admin_user_api'
@@ -21,8 +21,60 @@ import e2e_test_api from './e2e_test_api'
export const api = new Hono<HonoCustomType>()
const normalizeAddressCreationSettingsUpdate = (
value: unknown
): {
shouldUpdate: boolean,
shouldClear: boolean,
nextEnableSubdomainMatch?: boolean,
} | null => {
if (typeof value === 'undefined') {
return {
shouldUpdate: false,
shouldClear: false,
};
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const nextEnableSubdomainMatch = (value as Record<string, unknown>).enableSubdomainMatch;
if (typeof nextEnableSubdomainMatch === 'undefined') {
return {
shouldUpdate: false,
shouldClear: false,
};
}
// null 代表“清空后台覆盖,恢复为未设置并回退到 env”这是给前端三态显式使用的正式路径。
if (nextEnableSubdomainMatch === null) {
return {
shouldUpdate: true,
shouldClear: true,
};
}
if (typeof nextEnableSubdomainMatch !== 'boolean') {
return null;
}
return {
shouldUpdate: true,
shouldClear: false,
nextEnableSubdomainMatch,
};
}
api.get('/admin/address', async (c) => {
const { limit, offset, query } = c.req.query();
const { limit, offset, query, sort_by, sort_order } = c.req.query();
const allowedSortColumns: Record<string, string> = {
'id': 'a.id',
'name': 'a.name',
'created_at': 'a.created_at',
'updated_at': 'a.updated_at',
'source_meta': 'a.source_meta',
'mail_count': 'mail_count',
'send_count': 'send_count',
};
const sortColumn = Object.hasOwn(allowedSortColumns, sort_by) ? allowedSortColumns[sort_by] : 'a.id';
const sortDirection = sort_order === 'ascend' ? 'asc' : 'desc';
const orderBy = `${sortColumn} ${sortDirection}`;
if (query) {
return await handleListQuery(c,
`SELECT a.*,`
@@ -31,7 +83,7 @@ api.get('/admin/address', async (c) => {
+ ` FROM address a`
+ ` where name like ?`,
`SELECT count(*) as count FROM address where name like ?`,
[`%${query}%`], limit, offset
[`%${query}%`], limit, offset, orderBy
);
}
return await handleListQuery(c,
@@ -40,12 +92,12 @@ api.get('/admin/address', async (c) => {
+ ` (SELECT COUNT(*) FROM sendbox WHERE address = a.name) AS send_count`
+ ` FROM address a`,
`SELECT count(*) as count FROM address`,
[], limit, offset
[], limit, offset, orderBy
);
})
api.post('/admin/new_address', async (c) => {
const { name, domain, enablePrefix } = await c.req.json();
const { name, domain, enablePrefix, enableRandomSubdomain } = await c.req.json();
const msgs = i18n.getMessagesbyContext(c);
if (!name) {
return c.text(msgs.RequiredFieldMsg, 400)
@@ -53,6 +105,7 @@ api.post('/admin/new_address', async (c) => {
try {
const res = await newAddress(c, {
name, domain, enablePrefix,
enableRandomSubdomain: getBooleanValue(enableRandomSubdomain),
checkLengthByConfig: false,
addressPrefix: null,
checkAllowDomains: false,
@@ -281,13 +334,19 @@ api.get('/admin/account_settings', async (c) => {
const fromBlockList = c.env.KV ? await c.env.KV.get<string[]>(CONSTANTS.EMAIL_KV_BLACK_LIST, 'json') : [];
const emailRuleSettings = await getJsonSetting<EmailRuleSettings>(c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY);
const noLimitSendAddressList = await getJsonSetting(c, CONSTANTS.NO_LIMIT_SEND_ADDRESS_LIST_KEY);
const addressCreationSettings = await getAddressCreationSettings(c);
const addressCreationSubdomainMatchStatus = await getAddressCreationSubdomainMatchStatus(c, addressCreationSettings);
return c.json({
blockList: blockList || [],
sendBlockList: sendBlockList || [],
verifiedAddressList: verifiedAddressList || [],
fromBlockList: fromBlockList || [],
noLimitSendAddressList: noLimitSendAddressList || [],
emailRuleSettings: emailRuleSettings || {}
emailRuleSettings: emailRuleSettings || {},
addressCreationSettings: typeof addressCreationSettings.enableSubdomainMatch === 'boolean'
? { enableSubdomainMatch: addressCreationSettings.enableSubdomainMatch }
: {},
addressCreationSubdomainMatchStatus,
})
} catch (error) {
console.error(error);
@@ -300,14 +359,22 @@ api.post('/admin/account_settings', async (c) => {
/** @type {{ blockList: Array<string>, sendBlockList: Array<string> }} */
const {
blockList, sendBlockList, noLimitSendAddressList,
verifiedAddressList, fromBlockList, emailRuleSettings
verifiedAddressList, fromBlockList, emailRuleSettings, addressCreationSettings
} = await c.req.json();
if (!blockList || !sendBlockList || !verifiedAddressList) {
return c.text(msgs.InvalidInputMsg, 400)
}
const addressCreationSettingsUpdate = normalizeAddressCreationSettingsUpdate(addressCreationSettings);
if (!addressCreationSettingsUpdate) {
return c.text(msgs.InvalidInputMsg, 400)
}
if (!c.env.SEND_MAIL && verifiedAddressList.length > 0) {
return c.text(msgs.EnableSendMailMsg, 400)
}
// 所有输入依赖都先校验,再执行任意写入,避免接口返回 400 时出现部分设置已落库的半成功状态。
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text(msgs.EnableKVMsg, 400)
}
await saveSetting(
c, CONSTANTS.ADDRESS_BLOCK_LIST_KEY,
JSON.stringify(blockList)
@@ -320,9 +387,6 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.VERIFIED_ADDRESS_LIST_KEY,
JSON.stringify(verifiedAddressList)
)
if (fromBlockList?.length > 0 && !c.env.KV) {
return c.text(msgs.EnableKVMsg, 400)
}
if (fromBlockList?.length > 0 && c.env.KV) {
await c.env.KV.put(CONSTANTS.EMAIL_KV_BLACK_LIST, JSON.stringify(fromBlockList))
}
@@ -334,6 +398,20 @@ api.post('/admin/account_settings', async (c) => {
c, CONSTANTS.EMAIL_RULE_SETTINGS_KEY,
JSON.stringify(emailRuleSettings || {})
)
if (addressCreationSettingsUpdate.shouldUpdate) {
if (addressCreationSettingsUpdate.shouldClear) {
await c.env.DB.prepare(
`DELETE FROM settings WHERE key = ?`
).bind(CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY).run();
} else {
await saveSetting(
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY,
JSON.stringify({
enableSubdomainMatch: addressCreationSettingsUpdate.nextEnableSubdomainMatch
})
)
}
}
return c.json({
success: true
})

View File

@@ -1,7 +1,8 @@
import { Context } from "hono";
import { CONSTANTS } from "../constants";
import { WebhookSettings } from "../models";
import { WebhookSettings, RawMailRow } from "../models";
import { commonParseMail, sendWebhook } from "../common";
import { resolveRawEmail } from "../gzip";
async function getWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.env.KV.get<WebhookSettings>(
@@ -21,10 +22,12 @@ async function saveWebhookSettings(c: Context<HonoCustomType>): Promise<Response
async function testWebhookSettings(c: Context<HonoCustomType>): Promise<Response> {
const settings = await c.req.json<WebhookSettings>();
// random raw email
const { id: mailId, raw } = await c.env.DB.prepare(
`SELECT id, raw FROM raw_mails ORDER BY RANDOM() LIMIT 1`
).first<{ id: string, raw: string }>() || {};
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw || "" };
const mailRow = await c.env.DB.prepare(
`SELECT * FROM raw_mails ORDER BY RANDOM() LIMIT 1`
).first<RawMailRow>();
const mailId = mailRow?.id;
const raw = mailRow ? await resolveRawEmail(mailRow) : "";
const parsedEmailContext: ParsedEmailContext = { rawEmail: raw };
const parsedEmail = await commonParseMail(parsedEmailContext);
const res = await sendWebhook(settings, {
id: mailId || "0",

View File

@@ -24,6 +24,9 @@ export default {
"SUBDOMAIN_FORWARD_ADDRESS_LIST": utils.getJsonObjectValue<SubdomainForwardAddressList[]>(c.env.SUBDOMAIN_FORWARD_ADDRESS_LIST),
"DEFAULT_DOMAINS": utils.getDefaultDomains(c),
"DOMAINS": utils.getDomains(c),
"ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH": utils.getBooleanValue(c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH),
"RANDOM_SUBDOMAIN_DOMAINS": utils.getRandomSubdomainDomains(c),
"RANDOM_SUBDOMAIN_LENGTH": utils.getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, 8),
"DOMAIN_LABELS": utils.getStringArray(c.env.DOMAIN_LABELS),
"HAS_JWT_SECRET": !!utils.getStringValue(c.env.JWT_SECRET),

View File

@@ -26,6 +26,7 @@ api.get('/open_api/settings', async (c) => {
"maxAddressLen": utils.getIntValue(c.env.MAX_ADDRESS_LEN, 30),
"defaultDomains": utils.getDefaultDomains(c),
"domains": utils.getDomains(c),
"randomSubdomainDomains": utils.getRandomSubdomainDomains(c),
"domainLabels": utils.getStringArray(c.env.DOMAIN_LABELS),
"needAuth": needAuth,
"adminContact": c.env.ADMIN_CONTACT,
@@ -44,7 +45,8 @@ api.get('/open_api/settings', async (c) => {
"showGithub": !utils.getBooleanValue(c.env.DISABLE_SHOW_GITHUB),
"disableAdminPasswordCheck": utils.getBooleanValue(c.env.DISABLE_ADMIN_PASSWORD_CHECK),
"enableAddressPassword": utils.getBooleanValue(c.env.ENABLE_ADDRESS_PASSWORD),
"statusUrl": utils.getStringValue(c.env.STATUS_URL)
"statusUrl": utils.getStringValue(c.env.STATUS_URL),
"enableGlobalTurnstileCheck": utils.isGlobalTurnstileEnabled(c)
});
})

View File

@@ -2,13 +2,29 @@ import { Context } from 'hono';
import { Jwt } from 'hono/utils/jwt'
import { WorkerMailerOptions } from 'worker-mailer';
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue } from './utils';
import { getBooleanValue, getDomains, getStringValue, getIntValue, getUserRoles, getDefaultDomains, getJsonSetting, getAnotherWorkerList, hashPassword, getJsonObjectValue, getRandomSubdomainDomains } from './utils';
import { unbindTelegramByAddress } from './telegram_api/common';
import { CONSTANTS } from './constants';
import { AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
import { AddressCreationSettings, AdminWebhookSettings, WebhookMail, WebhookSettings } from './models';
import i18n from './i18n';
const DEFAULT_NAME_REGEX = /[^a-z0-9]/g;
const DEFAULT_RANDOM_SUBDOMAIN_LENGTH = 8;
const MAX_RANDOM_SUBDOMAIN_ATTEMPTS = 5;
const MAX_DOMAIN_LENGTH = 253;
const DOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const normalizeDomainValue = (domain: string): string => {
return domain.trim().toLowerCase();
}
const isValidDomainLabel = (label: string): boolean => {
return DOMAIN_LABEL_RE.test(label);
}
const areValidDomainLabels = (labels: string[]): boolean => {
return labels.length > 0 && labels.every((label) => isValidDomainLabel(label));
}
/**
* Check if send mail is enabled for a specific domain
@@ -66,6 +82,117 @@ export const generateRandomName = (c: Context<HonoCustomType>): string => {
return fullName.substring(0, Math.min(fullName.length, maxLength));
};
const generateRandomSubdomain = (c: Context<HonoCustomType>): string => {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789";
const length = Math.min(
Math.max(getIntValue(c.env.RANDOM_SUBDOMAIN_LENGTH, DEFAULT_RANDOM_SUBDOMAIN_LENGTH), 1),
63
);
let subdomain = "";
for (let i = 0; i < length; i++) {
subdomain += charset.charAt(Math.floor(Math.random() * charset.length));
}
return subdomain;
}
const allowRandomSubdomainForDomain = (
c: Context<HonoCustomType>,
domain: string
): boolean => {
const normalizedDomain = normalizeDomainValue(domain);
return getRandomSubdomainDomains(c)
.map((item) => normalizeDomainValue(item))
.includes(normalizedDomain);
}
const isCreateAddressSubdomainMatchEnvConfigured = (c: Context<HonoCustomType>): boolean => {
return c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== undefined
&& c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== null
&& c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH !== "";
}
export const getAddressCreationSettings = async (
c: Context<HonoCustomType>
): Promise<AddressCreationSettings> => {
const value = await getJsonSetting<AddressCreationSettings>(
c, CONSTANTS.ADDRESS_CREATION_SETTINGS_KEY
);
return new AddressCreationSettings(value);
}
export const getAddressCreationSubdomainMatchStatus = async (
c: Context<HonoCustomType>,
existingSettings?: AddressCreationSettings
): Promise<{
envConfigured: boolean,
envEnabled: boolean,
storedEnabled: boolean | undefined,
effectiveEnabled: boolean,
}> => {
const envConfigured = isCreateAddressSubdomainMatchEnvConfigured(c);
const envEnabled = getBooleanValue(c.env.ENABLE_CREATE_ADDRESS_SUBDOMAIN_MATCH);
const addressCreationSettings = existingSettings || await getAddressCreationSettings(c);
const storedEnabled = addressCreationSettings.enableSubdomainMatch;
// 业务约束env=false 作为全局 kill switch后台开关不能强行打开。
const effectiveEnabled = envConfigured && !envEnabled
? false
: typeof storedEnabled === "boolean"
? storedEnabled
: envEnabled;
return {
envConfigured,
envEnabled,
storedEnabled,
effectiveEnabled,
};
}
const findMatchedAllowedDomain = (
domain: string,
allowDomains: string[],
enableSubdomainMatch: boolean,
): string | null => {
const normalizedDomain = normalizeDomainValue(domain);
if (normalizedDomain.length > MAX_DOMAIN_LENGTH) {
return null;
}
const domainLabels = normalizedDomain.split('.');
if (!areValidDomainLabels(domainLabels)) {
return null;
}
const normalizedAllowDomains = allowDomains.map((allowDomain) => normalizeDomainValue(allowDomain));
if (normalizedAllowDomains.includes(normalizedDomain)) {
return normalizedDomain;
}
if (!enableSubdomainMatch) {
return null;
}
const matchedDomain = [...normalizedAllowDomains]
.sort((a, b) => b.length - a.length)
.find((allowDomain) => {
if (allowDomain.length > MAX_DOMAIN_LENGTH) {
return false;
}
const allowDomainLabels = allowDomain.split('.');
if (!areValidDomainLabels(allowDomainLabels)) {
return false;
}
if (domainLabels.length <= allowDomainLabels.length) {
return false;
}
const prefixLabels = domainLabels.slice(0, domainLabels.length - allowDomainLabels.length);
if (!areValidDomainLabels(prefixLabels)) {
return false;
}
return allowDomainLabels.every((label, index) => {
return domainLabels[domainLabels.length - allowDomainLabels.length + index] === label;
});
});
return matchedDomain || null;
}
const checkNameRegex = (c: Context<HonoCustomType>, name: string) => {
let error = null;
try {
@@ -148,12 +275,42 @@ const generatePasswordForAddress = async (
return plainPassword;
}
const insertAddressRecord = async (
c: Context<HonoCustomType>,
address: string,
sourceMeta: string | undefined | null,
msgs: ReturnType<typeof i18n.getMessagesbyContext>
): Promise<void> => {
try {
const result = await c.env.DB.prepare(
`INSERT INTO address(name, source_meta) VALUES(?, ?)`
).bind(address, sourceMeta).run();
if (!result.success) {
throw new Error(msgs.FailedCreateAddressMsg)
}
} catch (e) {
const message = (e as Error).message;
// Fallback: source_meta field may not exist, try without it
if (message && message.includes("source_meta")) {
const result = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(address).run();
if (!result.success) {
throw new Error(msgs.FailedCreateAddressMsg)
}
return;
}
throw e;
}
}
export const newAddress = async (
c: Context<HonoCustomType>,
{
name,
domain,
enablePrefix,
enableRandomSubdomain = false,
checkLengthByConfig = true,
addressPrefix = null,
checkAllowDomains = true,
@@ -162,13 +319,14 @@ export const newAddress = async (
}: {
name: string, domain: string | undefined | null,
enablePrefix: boolean,
enableRandomSubdomain?: boolean,
checkLengthByConfig?: boolean,
addressPrefix?: string | undefined | null,
checkAllowDomains?: boolean,
enableCheckNameRegex?: boolean,
sourceMeta?: string | undefined | null,
}
): Promise<{ address: string, jwt: string, password?: string | null }> => {
): Promise<{ address: string, jwt: string, password?: string | null, address_id: number }> => {
const msgs = i18n.getMessagesbyContext(c);
// trim whitespace and remove special characters
name = name.trim().replace(getNameRegex(c), '')
@@ -206,60 +364,71 @@ export const newAddress = async (
if (!domain && allowDomains.length > 0) {
const createAddressDefaultDomainFirst = getBooleanValue(c.env.CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST);
if (createAddressDefaultDomainFirst) {
domain = allowDomains[0];
domain = normalizeDomainValue(allowDomains[0]);
} else {
domain = allowDomains[Math.floor(Math.random() * allowDomains.length)];
domain = normalizeDomainValue(allowDomains[Math.floor(Math.random() * allowDomains.length)]);
}
} else if (typeof domain === "string") {
domain = normalizeDomainValue(domain);
}
const { effectiveEnabled: enableSubdomainMatch } = await getAddressCreationSubdomainMatchStatus(c);
const matchedAllowDomain = domain
? findMatchedAllowedDomain(domain, allowDomains, enableSubdomainMatch)
: null;
// check domain is valid
if (!domain || !allowDomains.includes(domain)) {
if (!domain || !matchedAllowDomain) {
throw new Error(msgs.InvalidDomainMsg)
}
// create address
name = name + "@" + domain;
try {
// Try insert with source_meta field first
const result = await c.env.DB.prepare(
`INSERT INTO address(name, source_meta) VALUES(?, ?)`
).bind(name, sourceMeta).run();
if (!result.success) {
throw new Error(msgs.FailedCreateAddressMsg)
}
await updateAddressUpdatedAt(c, name);
} catch (e) {
const message = (e as Error).message;
// Fallback: source_meta field may not exist, try without it
if (message && message.includes("source_meta")) {
const result = await c.env.DB.prepare(
`INSERT INTO address(name) VALUES(?)`
).bind(name).run();
if (!result.success) {
throw new Error(msgs.FailedCreateAddressMsg)
if (enableRandomSubdomain && !allowRandomSubdomainForDomain(c, domain)) {
throw new Error(msgs.RandomSubdomainNotAllowedMsg)
}
const maxAttempts = enableRandomSubdomain ? MAX_RANDOM_SUBDOMAIN_ATTEMPTS : 1;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const addressDomain = enableRandomSubdomain
? `${generateRandomSubdomain(c)}.${domain}`
: domain;
const address = `${name}@${addressDomain}`;
try {
await insertAddressRecord(c, address, sourceMeta, msgs);
await updateAddressUpdatedAt(c, address);
const address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(address).first<number>("id");
if (!address_id) {
throw new Error(msgs.FailedCreateAddressMsg);
}
// 如果启用地址密码功能,自动生成密码
const generatedPassword = await generatePasswordForAddress(c, address);
// create jwt
const jwt = await Jwt.sign({
address: address,
address_id: address_id
}, c.env.JWT_SECRET, "HS256")
return {
jwt: jwt,
address: address,
password: generatedPassword,
address_id: address_id,
}
} catch (e) {
const message = (e as Error).message;
if (message && message.includes("UNIQUE")) {
if (enableRandomSubdomain && attempt < maxAttempts - 1) {
continue;
}
throw new Error(msgs.AddressAlreadyExistsMsg)
}
await updateAddressUpdatedAt(c, name);
} else if (message && message.includes("UNIQUE")) {
throw new Error(msgs.AddressAlreadyExistsMsg)
} else {
throw new Error(msgs.FailedCreateAddressMsg)
}
}
const address_id = await c.env.DB.prepare(
`SELECT id FROM address where name = ?`
).bind(name).first<number>("id");
// 如果启用地址密码功能,自动生成密码
const generatedPassword = await generatePasswordForAddress(c, name);
// create jwt
const jwt = await Jwt.sign({
address: name,
address_id: address_id
}, c.env.JWT_SECRET, "HS256")
return {
jwt: jwt,
address: name,
password: generatedPassword,
}
throw new Error(msgs.FailedCreateAddressMsg)
}
const checkNameBlockList = async (
@@ -425,7 +594,9 @@ export const handleListQuery = async (
c: Context<HonoCustomType>,
query: string, countQuery: string, params: string[],
limit: string | number | undefined | null,
offset: string | number | undefined | null
offset: string | number | undefined | null,
/** Must be pre-validated (e.g. whitelist), NOT raw user input. Interpolated directly into SQL. */
orderBy?: string
): Promise<Response> => {
const msgs = i18n.getMessagesbyContext(c);
if (typeof limit === "string") {
@@ -440,7 +611,8 @@ export const handleListQuery = async (
if (offset == null || offset == undefined || offset < 0) {
return c.text(msgs.InvalidOffsetMsg, 400)
}
const resultsQuery = `${query} order by id desc limit ? offset ?`;
const orderClause = orderBy || 'id desc';
const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`;
const { results } = await c.env.DB.prepare(resultsQuery).bind(
...params, limit, offset
).all();
@@ -450,13 +622,41 @@ export const handleListQuery = async (
return c.json({ results, count });
}
/**
* handleListQuery variant for raw_mails: resolves raw_blob → raw after query.
*/
export const handleMailListQuery = async (
c: Context<HonoCustomType>,
query: string, countQuery: string, params: string[],
limit: string | number | undefined | null,
offset: string | number | undefined | null,
orderBy?: string
): Promise<Response> => {
const { resolveRawEmailList } = await import('./gzip');
const msgs = i18n.getMessagesbyContext(c);
if (typeof limit === "string") limit = parseInt(limit);
if (typeof offset === "string") offset = parseInt(offset);
if (!limit || limit < 0 || limit > 100) return c.text(msgs.InvalidLimitMsg, 400);
if (offset == null || offset == undefined || offset < 0) return c.text(msgs.InvalidOffsetMsg, 400);
const orderClause = orderBy || 'id desc';
const resultsQuery = `${query} order by ${orderClause} limit ? offset ?`;
const { results } = await c.env.DB.prepare(resultsQuery).bind(
...params, limit, offset
).all();
const resolvedResults = await resolveRawEmailList(results);
const count = offset == 0 ? await c.env.DB.prepare(
countQuery
).bind(...params).first("count") : 0;
return c.json({ results: resolvedResults, count });
}
export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): Promise<{
sender: string,
subject: string,
text: string,
html: string,
headers?: Record<string, string>[]
headers?: Record<string, string>[],
attachments?: ParsedEmailAttachment[],
} | undefined> => {
// check parsed email context is valid
if (!parsedEmailContext || !parsedEmailContext.rawEmail) {
@@ -467,7 +667,7 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
return parsedEmailContext.parsedEmail;
}
const raw_mail = parsedEmailContext.rawEmail;
// TODO: WASM parse email
// NOTE: WASM parse email
// try {
// const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
@@ -480,6 +680,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
// (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) {
@@ -494,6 +700,12 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
text: parsedEmail.text || "",
html: parsedEmail.html || "",
headers: parsedEmail.headers || [],
attachments: (parsedEmail.attachments || []).map(att => ({
filename: att.filename || "attachment",
mimeType: att.mimeType || "application/octet-stream",
content: new Uint8Array(att.content),
disposition: att.disposition || "attachment",
})),
};
return parsedEmailContext.parsedEmail;
}
@@ -605,7 +817,7 @@ export async function triggerWebhook(
subject: parsedEmail?.subject || "",
raw: parsedEmailContext.rawEmail || "",
parsedText: parsedEmail?.text || "",
parsedHtml: parsedEmail?.html || ""
parsedHtml: parsedEmail?.html || "",
}
for (const settings of webhookList) {
const res = await sendWebhook(settings, webhookMail);

View File

@@ -1,15 +1,16 @@
export const CONSTANTS = {
VERSION: 'v' + '1.4.0',
VERSION: 'v' + '1.5.0',
// DB Version
DB_VERSION_KEY: 'db_version',
DB_VERSION: "v0.0.6",
DB_VERSION: "v0.0.7",
// DB settings
ADDRESS_BLOCK_LIST_KEY: 'address_block_list',
SEND_BLOCK_LIST_KEY: 'send_block_list',
AUTO_CLEANUP_KEY: 'auto_cleanup',
USER_SETTINGS_KEY: 'user_settings',
ADDRESS_CREATION_SETTINGS_KEY: 'address_creation_settings',
OAUTH2_SETTINGS_KEY: 'oauth2_settings',
VERIFIED_ADDRESS_LIST_KEY: 'verified_address_list',
NO_LIMIT_SEND_ADDRESS_LIST_KEY: 'no_limit_send_address_list',

View File

@@ -1,6 +1,26 @@
import { createMimeMessage } from "mimetext";
import { getBooleanValue } from "../utils";
/**
* Check if the sender matches the source_prefix filter.
* - empty/undefined: match all senders
* - starts and ends with `/`: treat as regex (e.g. `/.*@example\.com$/`)
* - otherwise: legacy startsWith match
*/
function matchSender(from: string, sourcePrefix: string | undefined): boolean {
if (!sourcePrefix) return true;
if (sourcePrefix.startsWith("/") && sourcePrefix.endsWith("/") && sourcePrefix.length > 2) {
try {
const regex = new RegExp(sourcePrefix.slice(1, -1));
return regex.test(from);
} catch (error) {
console.error("Invalid regex in source_prefix:", sourcePrefix, error);
return false;
}
}
return from.startsWith(sourcePrefix);
}
export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings): Promise<void> => {
const message_id = message.headers.get("Message-ID");
// auto reply email
@@ -9,7 +29,10 @@ export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings
const results = await env.DB.prepare(
`SELECT * FROM auto_reply_mails where address = ? and enabled = 1`
).bind(message.to).first<Record<string, string>>();
if (results && results.source_prefix && message.from.startsWith(results.source_prefix)) {
if (results && matchSender(message.from, results.source_prefix)) {
if (!results.subject || !results.message) {
console.log("auto-reply using defaults:", !results.subject ? "subject" : "", !results.message ? "message" : "");
}
const msg = createMimeMessage();
msg.setHeader("In-Reply-To", message_id);
msg.setSender({
@@ -22,14 +45,18 @@ export const auto_reply = async (message: ForwardableEmailMessage, env: Bindings
contentType: 'text/plain',
data: results.message || "This is an auto-reply message, please reconact later."
});
const { EmailMessage } = await import('cloudflare:email');
const replyMessage = new EmailMessage(
message.to,
message.from,
msg.asRaw()
);
// @ts-ignore
await message.reply(replyMessage);
if (getBooleanValue(env.E2E_TEST_MODE)) {
await message.reply(msg.asRaw());
} else {
const { EmailMessage } = await import('cloudflare:email');
const replyMessage = new EmailMessage(
message.to,
message.from,
msg.asRaw()
);
// @ts-ignore
await message.reply(replyMessage);
}
}
} catch (error) {
console.log("reply email error", error);

View File

@@ -1,6 +1,6 @@
import { Context } from "hono";
import { getJsonSetting } from "../utils";
import { getBooleanValue, getJsonSetting } from "../utils";
import { sendMailToTelegram } from "../telegram_api";
import { auto_reply } from "./auto_reply";
import { isBlocked } from "./black_list";
@@ -11,6 +11,7 @@ import { extractEmailInfo } from "./ai_extract";
import { forwardEmail } from "./forward";
import { EmailRuleSettings } from "../models";
import { CONSTANTS } from "../constants";
import { compressText } from "../gzip";
async function email(message: ForwardableEmailMessage, env: Bindings, ctx: ExecutionContext) {
@@ -65,11 +66,49 @@ async function email(message: ForwardableEmailMessage, env: Bindings, ctx: Execu
const message_id = message.headers.get("Message-ID");
// save email
try {
const { success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, parsedEmailContext.rawEmail, message_id
).run();
let success = false;
if (getBooleanValue(env.ENABLE_MAIL_GZIP)) {
let compressed: ArrayBuffer | null = null;
try {
compressed = await compressText(parsedEmailContext.rawEmail);
} catch (gzipError) {
console.error("gzip compression failed, falling back to plaintext", gzipError);
}
if (compressed) {
try {
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw_blob, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, compressed, message_id
).run());
} catch (dbError) {
// Fallback to plaintext only if raw_blob column is missing (migration not applied)
const errMsg = String(dbError);
if (errMsg.includes('raw_blob') || errMsg.includes('no such column')) {
console.error("raw_blob column missing, falling back to plaintext", dbError);
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, parsedEmailContext.rawEmail, message_id
).run());
} else {
throw dbError;
}
}
} else {
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, parsedEmailContext.rawEmail, message_id
).run());
}
} else {
({ success } = await env.DB.prepare(
`INSERT INTO raw_mails (source, address, raw, message_id) VALUES (?, ?, ?, ?)`
).bind(
message.from, message.to, parsedEmailContext.rawEmail, message_id
).run());
}
if (!success) {
message.setReject(`Failed save message to ${message.to}`);
console.error(`Failed save message from ${message.from} to ${message.to}`);

48
worker/src/gzip.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Gzip compression/decompression utilities for D1 BLOB storage.
* Uses Web Standard CompressionStream/DecompressionStream (native in CF Workers).
*/
import { RawMailRow } from "./models";
export async function compressText(text: string): Promise<ArrayBuffer> {
const stream = new Blob([text]).stream().pipeThrough(new CompressionStream('gzip'));
return new Response(stream).arrayBuffer();
}
export async function decompressBlob(buffer: ArrayBuffer): Promise<string> {
const stream = new Blob([buffer]).stream().pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).text();
}
/**
* Resolve the raw email text from either raw_blob (gzip) or raw (plaintext) field.
*/
export async function resolveRawEmail(row: RawMailRow): Promise<string> {
if (row.raw_blob) {
try {
// D1 returns BLOB as Array<number>, convert to ArrayBuffer for decompression
return await decompressBlob(new Uint8Array(row.raw_blob as ArrayLike<number>).buffer);
} catch (e) {
console.error("decompressBlob failed, fallback to raw field", e);
return row.raw ?? '';
}
}
return row.raw ?? '';
}
/**
* Resolve a single row: decompress raw_blob if present, strip raw_blob from result.
*/
export async function resolveRawEmailRow(row: RawMailRow): Promise<RawMailRow> {
const raw = await resolveRawEmail(row);
const { raw_blob: _, ...rest } = row;
return { ...rest, raw };
}
/**
* Batch resolve raw emails for list queries using Promise.all.
*/
export async function resolveRawEmailList(rows: RawMailRow[]): Promise<RawMailRow[]> {
return Promise.all(rows.map(row => resolveRawEmailRow(row)));
}

View File

@@ -57,6 +57,7 @@ const messages: LocaleMessages = {
NameTooShortMsg: "Name is too short",
NameTooLongMsg: "Name is too long",
InvalidDomainMsg: "Invalid domain",
RandomSubdomainNotAllowedMsg: "Random subdomain is not enabled for this domain",
AddressAlreadyExistsMsg: "Address already exists",
MaxAddressCountReachedMsg: "Max address count reached",
AddressNotBindedMsg: "Address is not binded",

View File

@@ -55,6 +55,7 @@ export type LocaleMessages = {
NameTooShortMsg: string
NameTooLongMsg: string
InvalidDomainMsg: string
RandomSubdomainNotAllowedMsg: string
AddressAlreadyExistsMsg: string
MaxAddressCountReachedMsg: string
AddressNotBindedMsg: string

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