Compare commits

...

396 Commits

Author SHA1 Message Date
Dream Hunter
03965f3612 fix(imap): fix mojibake in nested emails, empty headers, and date handling (#909)
* fix(imap): fix mojibake in nested emails, empty headers, and date handling

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

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

* docs: consolidate IMAP changelog entries into single line

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

---------

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

* Apply suggestions from code review

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

---------

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

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

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

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

* docs: mention Android via browser in changelog

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

---------

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

* chore: add missing entries to worker .gitignore

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

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

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

---------

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

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

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

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

Closes #894

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

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

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

* refactor: remove unused i18n attachment messages

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

* refactor: use sendMediaGroup for batch attachment sending

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

* refactor: remove redundant commonParseMail call, use cached result

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: unify attachment sending with sendMediaGroup for all cases

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

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

* chore: reduce sendMediaGroup batch size to 6

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

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

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

* chore: regenerate wasm parser patch with attachments support

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

* feat: add ENABLE_TG_PUSH_ATTACHMENT env var to control attachment push

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

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

---------

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

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

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

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

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

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

* docs: add ENABLE_LOGIN_TURNSTILE_CHECK to wrangler.toml.template

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

* fix: ensure openSettings loaded before admin login modal

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

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

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

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

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

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

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

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

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

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

* fix: use unique IDs for multiple Turnstile instances

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

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

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

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

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

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

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

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

* fix: add enableLoginTurnstileCheck to store defaults, simplify changelog

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

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

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

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

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

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

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

* refactor: rename to ENABLE_GLOBAL_TURNSTILE_CHECK and add isGlobalTurnstileEnabled helper

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

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

* refactor: use utils.isGlobalTurnstileEnabled instead of named import

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

* test: add E2E tests for turnstile login endpoints

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

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

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

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

* fix: validate JWT payload has address field in credential_login

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* feat: upgrade @simplewebauthn packages from v10 to v13

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

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

* test: add passkey API E2E tests

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

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

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

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

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

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

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

* fix: allow crypto.subtle in Docker browser tests

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

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

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

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

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

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

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

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

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

* fix: store userJwt without JSON.stringify in localStorage

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

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

* refactor: clean up passkey API test per review feedback

Remove unused variables and rename test to match actual behavior.

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

---------

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

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

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

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

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

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

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

* feat: upgrade version to v1.5.0

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

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

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

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

* refactor: address CodeRabbit review suggestions

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

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

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

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

---------

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

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

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

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

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

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

* fix: use hyphenated cache-clearing in release skill

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: assert webhook request path in E2E test

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

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

---------

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

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

Closes #249

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

* test: add E2E tests for SMTP/IMAP STARTTLS

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

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

* fix: enforce auth over TLS when STARTTLS is configured

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

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

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

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

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

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

---------

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

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

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

* refactor: extract isAdmin() as shared utility function

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

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

* refactor: rename isAdmin to checkIsAdmin for consistency

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

* fix: address PR review comments

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

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

---------

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

Supersedes #872

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

Closes #864

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

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

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

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

---------

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

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

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

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

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

* chore: trigger CI

---------

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

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

Enable ENABLE_AUTO_REPLY in E2E wrangler config.

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

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

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

---------

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

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

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

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

---------

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

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

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

* test: remove unused address variable

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

---------

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

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

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

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

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

---------

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

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

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

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

* test: add XSS sanitization E2E screenshots

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

* chore: remove temporary screenshots from tree

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

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

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

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

---------

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

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

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

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

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

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

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

* refactor: remove unnecessary vitest exclude config

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

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

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

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

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

* test: add e2e screenshots for PR review

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

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

* chore: remove temporary test screenshots

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

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

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

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

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

* test: update e2e screenshots showing HTML formatting preserved

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

* chore: remove temporary screenshots from tree

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

* test: add SMTP send flow E2E screenshots with mailpit

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

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

* chore: remove temporary SMTP test screenshots from tree

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update lock files and version

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

* fix: restore accessTokenFormat as optional field

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

---------

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

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

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

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

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

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

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

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

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

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

* docs: update changelog for address UI

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

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

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

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

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

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

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

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

* fix: improve Telegram bot language preference feature

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update dependencies

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

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

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

* docs: update CHANGELOG for empty address cleanup feature

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: upgrade dependencies

---------

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-09-04 21:04:42 +08:00
Dream Hunter
648e9f7adf feat: add simplemode button at index (#714) 2025-08-27 20:22:52 +08:00
Dream Hunter
ab2bfdd00f feat: 邮件页面增加 上一封/下一封 按钮 (#712) 2025-08-24 19:14:50 +08:00
Dream Hunter
0565978930 feat: 账号设置页面增加 邮件转发规则 和 禁止接收未知地址邮件 配置 (#710) 2025-08-23 16:19:53 +08:00
Dream Hunter
89d8944e60 Docs: update UI install docs (#709) 2025-08-23 01:14:41 +08:00
Dream Hunter
4084771621 feat: |UI| Optimized minimalist mode homepage, added all email page f… (#708)
feat: |UI| Optimized minimalist mode homepage, added all email page functions (delete/download/attachments/...)
2025-08-23 00:23:47 +08:00
Dream Hunter
840496c48f feat: telegram check addrss exists (#705) 2025-08-08 13:26:45 +08:00
Dream Hunter
9843b35f54 feat: telegram use the random domain when not set (#704) 2025-08-08 13:07:00 +08:00
Dream Hunter
bfd66f5019 fix: worker node compat (#699) 2025-07-28 13:18:47 +08:00
Dream Hunter
0bc31360b0 feat: upgrade dependencies (#698) 2025-07-28 12:58:47 +08:00
Dream Hunter
267d9bb93e fix: oauth2 callback failed (#691) 2025-07-20 17:09:23 +08:00
Dream Hunter
2cc84d565c feat: upgrade dependencies (#690) 2025-07-19 13:26:10 +08:00
Dream Hunter
c96d180591 feat(oauth2): add default role assignment for new OAuth2 users (#688)
- Add default role assignment logic in OAuth2 login flow
- Import getStringValue and getUserRoles utilities
- Validate default role exists in system before assignment
- Use ON CONFLICT DO NOTHING to preserve existing user roles
- Add proper error handling for role assignment failures
2025-07-14 23:55:36 +08:00
Dream Hunter
1303b0f2a9 feat: |UI| add simple index (#684) 2025-06-28 15:52:19 +08:00
Dream Hunter
9f535a0a90 feature: update dependencies (#682) 2025-06-24 18:27:45 +08:00
Dream Hunter
70109785c6 feature: update readme (#680) 2025-06-22 20:41:21 +08:00
Dream Hunter
7fd10f2775 feature: update readme (#679) 2025-06-22 20:38:49 +08:00
Dream Hunter
f59b8c7a1b feature: update readme (#678) 2025-06-22 20:21:04 +08:00
Dream Hunter
312ac13185 feature: update readme (#677) 2025-06-22 20:08:07 +08:00
Dream Hunter
e6c582be9f feature: update address updated_at in multi api (#676) 2025-06-21 01:48:35 +08:00
Dream Hunter
483c429feb feature: update address updated_at in multi api (#675) 2025-06-21 01:41:28 +08:00
Dream Hunter
da5482e095 feature: update dependencies (#674) 2025-06-21 01:06:37 +08:00
Dream Hunter
de4646876a fix: imap cannot update message (#673) 2025-06-21 01:00:46 +08:00
Dream Hunter
bbc8a96811 fix: imap cannot update message (#672) 2025-06-21 00:57:44 +08:00
Dream Hunter
9ac9cd46b0 feat: cleanup support address and inactive address (#671) 2025-06-18 17:31:15 +08:00
Dream Hunter
c694b07380 fix: cron job not run when clean days is 0 (#670) 2025-06-18 13:15:32 +08:00
Dream Hunter
672c4c7273 fix: |UI| user mail page query word bug (#665) 2025-06-09 19:26:18 +08:00
Dream Hunter
ee023ac2e9 feat: update changelog (#664) 2025-06-09 19:09:28 +08:00
Dream Hunter
cc77bdf36d feat: add ALWAYS_SHOW_ANNOUNCEMENT option (#663) 2025-06-09 19:06:49 +08:00
Dream Hunter
dec309a0fd fix: github actions node version (#660) 2025-06-02 11:28:41 +08:00
Dream Hunter
9488543e44 fix: ui admin portal show after fetch user data (#659) 2025-05-20 17:55:33 +08:00
Dream Hunter
50326bcc98 feature: support init db in admin portal (#658) 2025-05-20 17:45:55 +08:00
Dream Hunter
272b624b9b feature: utils import (#652) 2025-05-07 00:54:47 +08:00
Dream Hunter
e230801a1c feature: update dependencies (#651) 2025-05-07 00:13:26 +08:00
Zyx-A
07833d5ca9 feature: 基于子域名转发到不同的邮箱中去 (#645) (#647) 2025-04-30 10:41:09 +08:00
Dream Hunter
101a561894 feature: auto refresh user token when token exp in 7 days (#644) 2025-04-26 21:22:26 +08:00
Dream Hunter
327962432a fix: some oauth2 need redirect_uri when get token (#643) 2025-04-26 20:56:47 +08:00
Dream Hunter
6051d49315 feature: version 0.10.0 (#640) 2025-04-24 02:04:40 +08:00
Dream Hunter
95f361743b feature: add /user_api/mails with filter params address and keyword (#639) 2025-04-24 02:01:21 +08:00
Dream Hunter
c6afc5d425 feat: support admin api bind address to user (#635) 2025-04-16 13:36:41 +08:00
Dream Hunter
466f53254b feat: docs: update worker doc (#633) 2025-04-16 00:07:12 +08:00
Dream Hunter
ce0a10e6de feat: |Admin Portal| optimized UI (#632) 2025-04-12 20:24:11 +08:00
Dream Hunter
26995982af feat: oatuh2 email key support jsonpath (#631) 2025-04-12 19:57:03 +08:00
Dream Hunter
0894ac0dc9 feat: support admin api bind address to user (#630) 2025-04-12 19:49:59 +08:00
Dream Hunter
47e2cb56b4 feat: support deploy worker with UI assets (#627) 2025-04-12 15:37:34 +08:00
Dream Hunter
32767176f0 feat: s3 attachment add delete (#625) 2025-04-07 20:17:56 +08:00
Dream Hunter
31eb6c23d1 feat: admin portal user page add user address manangement (#623) 2025-04-07 19:47:44 +08:00
Dream Hunter
91a859bbcf feat: support cleanDays max 1000 (#622) 2025-04-07 19:24:21 +08:00
Dream Hunter
525f5e2dce feat: support auto login with url query parameter (#606) 2025-03-16 14:20:24 +08:00
Dream Hunter
908fc0cc86 feat: |Doc| use shadow DOM render mail html (#604) 2025-03-08 10:53:45 +08:00
Dream Hunter
97d24b2087 feat: |Doc| add Google ads doc (#598) 2025-02-27 00:58:56 +08:00
Dream Hunter
983300acf4 feat: |UI| add loading for lazy load component (#597) 2025-02-27 00:36:13 +08:00
Dream Hunter
144a792cb2 feat: |UI| change SideMargin size base on gridMaxCols (#596) 2025-02-27 00:14:04 +08:00
Dream Hunter
278f0112d0 feat: |UI| change SideMargin size (#595) 2025-02-27 00:08:06 +08:00
Dream Hunter
764faebf9f feat: update dependencies && version to 0.9.1 (#594) 2025-02-26 23:58:37 +08:00
Dream Hunter
d4f0c82e42 feat: update dependencies && version to 0.9.1 (#593) 2025-02-26 23:36:08 +08:00
Dream Hunter
cf680e6349 feat: |UI| support google ads (#592) 2025-02-26 23:01:57 +08:00
Dream Hunter
c3987d364c feat: |Actions| Tag build add worker-with-wasm-mail-parser.zip (#590) 2025-02-22 18:51:44 +08:00
Dream Hunter
3a542a8391 feat: |Worker| NO_LIMIT_SEND_ROLE support multi role splited by ',' (#588) 2025-02-22 16:58:48 +08:00
Dream Hunter
241e0b7b28 feat: |Worker| multi language add messages (#587) 2025-02-20 01:41:34 +08:00
Dream Hunter
b43353ea47 feat: |Worker| multi language add messages (#586) 2025-02-20 01:05:02 +08:00
Dream Hunter
6c334d32f6 feat: |Worker| add var DEFAULT_LANG, zh/en (#585) 2025-02-20 00:42:48 +08:00
Dream Hunter
7889d2edea feat: |Worker| support multi language (#584) 2025-02-20 00:37:39 +08:00
Dream Hunter
2426e0b51a feat: update dependencies (#581) 2025-02-15 18:54:15 +08:00
Dream Hunter
61434ab6f7 feat: |Worker| support send mail by SMTP (#580) 2025-02-15 18:17:14 +08:00
Dream Hunter
7f6a02ca38 fix: |UI| date parse error at mobile devices (#575) 2025-01-30 22:42:27 +08:00
Dream Hunter
6ae3b0d85e feat: update docs (#574) 2025-01-24 17:36:59 +08:00
Dream Hunter
01e6cb1075 feat: |worker| health_check add JWT_SECRET and DOMAINS (#573) 2025-01-24 15:00:50 +08:00
Dream Hunter
814f6fada2 feat: |UI| admin worker config page add overflow: auto (#572) 2025-01-22 23:34:49 +08:00
Dream Hunter
31901aacc5 feat: update docs (#571) 2025-01-22 23:25:40 +08:00
Dream Hunter
fb9b9f6ae4 feat: update CHANGE LOG (#570) 2025-01-22 23:19:53 +08:00
Dream Hunter
095951ab45 feat: update docs (#569) 2025-01-22 23:14:38 +08:00
Dream Hunter
37614ce6fa feat: footer support html (#567) 2025-01-21 10:24:13 +08:00
Dream Hunter
3f81fbee6d feat: announcement support html (#566)
* feat: announcement support html

* feat: update dependencies
2025-01-20 13:53:40 +08:00
Dream Hunter
cf13236e7b fix: telegram mail page use iframe show email (#564) 2025-01-18 14:59:08 +08:00
Dream Hunter
36e9c611e6 feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTAC… (#563)
feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTACHMENT
2025-01-18 14:43:09 +08:00
Dream Hunter
047200c1c2 feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTAC… (#562)
feat: |Worker| add REMOVE_ALL_ATTACHMENT and REMOVE_EXCEED_SIZE_ATTACHMENT
2025-01-18 14:12:01 +08:00
Dream Hunter
a22add0e14 fix: telegram mail page use iframe show email (#561) 2025-01-18 13:52:09 +08:00
Dream Hunter
7b1c4cc72a fix: mail-parser-wasm parsedEmailContext cache (#560) 2025-01-18 13:26:09 +08:00
刘志聪
3870727a08 fix: rpc headers covert & typo (#559)
Co-authored-by: liuzhicong <liuzhicong@dhgate.com>
2025-01-16 00:20:02 +08:00
Dream Hunter
2bb033964c feat: update doc (#557) 2025-01-11 18:56:36 +08:00
Dream Hunter
9db5a00b35 feat: v0.8.5 && update dependencies && fix deprecated warning for `… (#556)
feat: v0.8.5 && update dependencies && fix `deprecated` warning for `mail-parser-wasm-worker`
2025-01-11 18:46:46 +08:00
Dream Hunter
e161eb5d14 fix: telegram query email do not pass parsedEmailContext (#555) 2025-01-11 18:14:34 +08:00
Dream Hunter
b604f56d56 feat: |Github Action| Deploy Backend add DEBUG_MODE for logging && BA… (#554)
feat: |Github Action| Deploy Backend add DEBUG_MODE for logging && BACKEND_USE_MAIL_WASM_PARSER to enable mail-parser-wasm-worker
2025-01-11 18:04:53 +08:00
Dream Hunter
52caf811f5 feat: add JUNK_MAIL_CHECK_LIST for check exits and passed item && add ParsedEmailContext to cache the parsed Email (#553)
* feat: Junk mail only check JUNK_MAIL_FORCE_PASS_LIST

* feat: add `JUNK_MAIL_CHECK_LIST` for check exits and passed item && add `ParsedEmailContext` to cache the parsed Email
2025-01-11 17:42:20 +08:00
Dream Hunter
ee3884914b Update CHANGELOG.md 2025-01-09 22:50:22 +08:00
Dream Hunter
844fc52bbc feat: |UI| add configAutoRefreshInterval && autoRefresh useStorage (#549)
* feat: |UI| add configAutoRefreshInterval && autoRefresh useStorage

* Update MailBox.vue

* Update MailBox.vue
2025-01-09 22:49:25 +08:00
Dream Hunter
b87b49f09d Update CHANGELOG.md 2025-01-08 20:04:11 +08:00
刘志聪
5bfa588f70 feat: trigger another worker (#547) 2025-01-08 20:02:48 +08:00
Dream Hunter
92620cdedb feat: add DISABLE_ANONYMOUS_USER_CREATE_EMAIL which only allow logi… (#545)
feat: add `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` which only allow login user create email address
2025-01-05 18:51:48 +08:00
Dream Hunter
e9748be9fe Update vite.config.js (#544) 2025-01-05 02:05:40 +08:00
Dream Hunter
479322c430 feat: |Telegram Bot| add new command to clean invalid jwts (#543) 2025-01-05 01:52:07 +08:00
Dream Hunter
934e58e23b fix: |UI| admin mails unknown page call wrong api (#542) 2025-01-05 01:14:36 +08:00
Dream Hunter
c964d77a59 feat: |UI| add JUNK_MAIL_FORCE_PASS_LIST (#539) 2024-12-30 18:38:33 +08:00
Dream Hunter
8a03d3e57f feat: |UI| admin portal user oauth config support delete (#538) 2024-12-30 18:08:17 +08:00
Dream Hunter
6caba7c863 feat: add docs (#537) 2024-12-28 13:52:08 +08:00
Dream Hunter
43e5bdc764 feat: update dependencies (#536) 2024-12-28 00:32:07 +08:00
Dream Hunter
7bec0daba4 feat: update docs (#534) 2024-12-27 10:47:14 +08:00
Dream Hunter
13e5adef17 feat: update docs (#533) 2024-12-26 22:23:41 +08:00
Dream Hunter
440238133e feat: |Github Action| add upstream sync and auto deploy frontend&&bac… (#528)
feat: |Github Action| add upstream sync and auto deploy frontend&&backend
2024-12-23 22:55:10 +08:00
Dream Hunter
4a881e2d2b feat: upgrade dependencies (#527) 2024-12-23 21:10:45 +08:00
Dream Hunter
b0bf7a5f13 feat: add NO_LIMIT_SEND_ADDRESS_LIST_KEY in admin account settings page (#525) 2024-12-22 15:52:53 +08:00
Dream Hunter
a9bb8785ba feat: support send mail from admin portal(no balance limit) (#524) 2024-12-22 15:40:26 +08:00
Dream Hunter
0b48baff6d fix: frontend github actions cannot use branch param to deploy (#520)
* Update frontend_deploy.yaml

* Update frontend_deploy.yaml
2024-12-19 17:58:54 +08:00
Dream Hunter
e0b5e80efd feat: |doc| update doc (#510) 2024-12-04 00:56:52 +08:00
Dream Hunter
b0e36ac2aa feat: |doc| update Telegram Bot doc (#509) 2024-12-04 00:33:47 +08:00
Dream Hunter
51db19c85b feat: |UI| add tip for multiple tag (#508) 2024-12-04 00:29:01 +08:00
Dream Hunter
e52b010aa4 feat: |doc| update doc (#507) 2024-12-03 22:04:46 +08:00
Dream Hunter
8f6793402c feat: |UI| add forward in mail page (#502) 2024-11-30 15:53:48 +08:00
Dream Hunter
e86c530116 feat: |UI| hide ID for user (#501) 2024-11-30 15:11:37 +08:00
Dream Hunter
0308f518da feat: upgrade dependencies && |doc| update ui install worker doc (#494) 2024-11-22 14:42:35 +08:00
Dream Hunter
3c2a8ed056 feat: remove service workbox html cache (#486) 2024-11-15 01:39:14 +08:00
Dream Hunter
5f45ec7c14 feat: remove service workbox (#485) 2024-11-15 01:22:43 +08:00
Dream Hunter
1b7ebc98c5 feat: support transfer address from user to user (#484)
* feat: support transfer address from user to user

* feat: remove service worker
2024-11-15 01:10:25 +08:00
Dream Hunter
c102004f4d feat: |UI| show local datetime string and add useUTCDate option (#483) 2024-11-15 00:04:17 +08:00
Dream Hunter
3c81e05a2f feat: |UI| random fake name support MAX_ADDRESS_LEN (#482) 2024-11-14 23:58:42 +08:00
Dream Hunter
5ff2ceb5e8 feat: pages support Cloudflare Zero Trust (#477) 2024-11-11 23:55:49 +08:00
Dream Hunter
6c82efb738 feat: docs: ui_install worker update (#476) 2024-11-08 13:09:28 +08:00
Dream Hunter
e99acdcc6e fix: CI (#471) 2024-11-07 01:11:00 +08:00
Dream Hunter
8f30505706 feat: v0.7.6 (#470) 2024-11-07 01:00:26 +08:00
Dream Hunter
ddfa2c5d03 feat: add ENABLE_CHECK_JUNK_MAIL (#469) 2024-11-07 00:58:15 +08:00
Dream Hunter
49b3f10838 feat: upgrade dependencies && add ci build telegram-frontend.zip (#467) 2024-11-06 23:42:39 +08:00
Dream Hunter
cc9ac67319 feat: upgrade dependencies (#448) 2024-09-27 22:30:37 +08:00
Dream Hunter
7cc2a2b576 feat: doc: add mail id and url in webhook (#444) 2024-09-09 22:49:53 +08:00
Dream Hunter
393c5902c3 feat: add mail id and url in webhook (#443) 2024-09-09 22:29:18 +08:00
Dream Hunter
5ece49a576 feat: telegram Set manually to avoid implicit call in (#442) 2024-09-09 20:59:12 +08:00
dependabot[bot]
de80857e2c build(deps): bump twisted from 24.3.0 to 24.7.0 in /smtp_proxy_server (#385)
Bumps [twisted](https://github.com/twisted/twisted) from 24.3.0 to 24.7.0.
- [Release notes](https://github.com/twisted/twisted/releases)
- [Changelog](https://github.com/twisted/twisted/blob/trunk/NEWS.rst)
- [Commits](https://github.com/twisted/twisted/compare/twisted-24.3.0...twisted-24.7.0)

---
updated-dependencies:
- dependency-name: twisted
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-27 01:40:45 +08:00
Dream Hunter
a57a42b2a1 fix: name check bug (#434) 2024-08-25 16:39:55 +08:00
Dream Hunter
a24cc1f642 fix: bugs && release v0.7.4 (#432) 2024-08-24 15:07:07 +08:00
Dream Hunter
4c6fd3c2af feat: UI add min-width for table page (#428) 2024-08-19 22:53:13 +08:00
Dream Hunter
1cf38c1768 feat: UI: add WorkerConfig && release v0.7.3 (#421) 2024-08-18 14:58:57 +08:00
Dream Hunter
b5b59acdb3 feat: add Oauth2 Login (#420) 2024-08-18 14:39:50 +08:00
Dream Hunter
6d4783e1cd fix: UI admin page show modal when no need password (#419) 2024-08-17 23:54:03 +08:00
Dream Hunter
34e3e1b439 fix: UI admin page show modal when no need password (#418) 2024-08-17 23:14:35 +08:00
Dream Hunter
56104cd23a fix: UI tab active icon wrong position (#416) 2024-08-17 01:46:40 +08:00
Dream Hunter
3664028e06 feat: add ADDRESS_CHECK_REGEX (#415) 2024-08-17 00:11:28 +08:00
Dream Hunter
9888f98d74 feat: update dependencies (#411) 2024-08-15 01:05:05 +08:00
Dream Hunter
ac5605f17f release v0.7.2 doc (#410) 2024-08-15 01:02:15 +08:00
Dream Hunter
a9719cb3ec release v0.7.2 (#409) 2024-08-15 00:56:15 +08:00
Dream Hunter
5f4978645b release v0.7.2 (#408) 2024-08-15 00:52:18 +08:00
Dream Hunter
621476cb79 feat: update webhook to support global webhook (#407) 2024-08-15 00:23:31 +08:00
Dream Hunter
c969c4b082 fix: DISABLE_ADMIN_PASSWORD_CHECK still show admin password modal (#406) 2024-08-14 22:52:45 +08:00
Dream Hunter
d90f54345d feat: add ADDRESS_REGEX (#401) 2024-08-13 23:21:19 +08:00
Dream Hunter
797b8bb019 fix: NO_LIMIT_SEND_ROLE no access token (#400) 2024-08-13 01:38:20 +08:00
Dream Hunter
7e5d142924 fix: NO_LIMIT_SEND_ROLE when user settings not call (#396) 2024-08-11 23:45:24 +08:00
Dream Hunter
c6d0307eac Release v0.7.1 2024-08-11 22:46:40 +08:00
Dream Hunter
ac31042e69 feat: add EMAIL_KV_BLACK_LIST (#394) 2024-08-11 20:34:10 +08:00
Dream Hunter
c733d3bf4d fix: get user role before all requests (#393) 2024-08-11 19:29:49 +08:00
Dream Hunter
bf1243f4c4 release: v0.7.0 (#387) 2024-08-11 00:21:15 +08:00
Dream Hunter
15063b2e97 feat: add DISABLE_ADMIN_PASSWORD_CHECK (#386) 2024-08-11 00:10:16 +08:00
Dream Hunter
fc07f1cd87 feat: add passkey (#384) 2024-08-10 23:56:05 +08:00
Dream Hunter
9246550cc5 feat: add NO_LIMIT_SEND_ROLE (#373) 2024-08-04 21:02:11 +08:00
Dream Hunter
979b6eae1a feat: add SHOW_GITHUB config (#372) 2024-08-04 14:36:24 +08:00
Dream Hunter
10da337a9c feat: add SHOW_GITHUB config (#371) 2024-08-04 14:34:35 +08:00
Dream Hunter
9c5e8857af feat: add loading when process mails (#367) 2024-07-27 23:14:18 +08:00
Dream Hunter
84b4baa99e feat: add .github/workflows/pr_agent.yml (#366) 2024-07-27 23:06:54 +08:00
Dream Hunter
b57d46244a feat: add loading when process mails (#364) 2024-07-27 22:30:38 +08:00
Dream Hunter
5faae8796d feat: add ADMIN_USER_ROLE for user access admin panel (#363) 2024-07-27 22:04:18 +08:00
666-eth
a0805bc0ce Docs: Update new-address-api.md (#360) 2024-07-23 13:47:37 +08:00
Dream Hunter
d0ccc3ded1 v0.6.1 2024-07-22 13:09:42 +08:00
Dream Hunter
163d9451f7 feat: worker: newAddress if domain is not set, use the first domain (#358) 2024-07-22 13:05:50 +08:00
Dream Hunter
60dda7e3fe feat: add ANNOUNCEMENT (#357) 2024-07-22 13:01:38 +08:00
Dream Hunter
384eb9b041 fix: imap proxy do not support password && cleanup days translate (#356) 2024-07-19 22:40:53 +08:00
tqjason
38816cbf0f Add new workflow action and Fix cleanup bug (#355)
* Create frontend_pagefunction_deploy.yaml

* Update frontend_pagefunction_deploy.yaml

* Update cleanup_api.ts

* Update common.ts

* Update cleanup_api.ts

* Update common.ts
2024-07-19 22:34:01 +08:00
Dream Hunter
d7d1ba6b64 feat: wrangler d1 execute dev add --remote (#352) 2024-07-15 12:04:14 +08:00
Dream Hunter
14725e9e9f feat: add USER_DEFAULT_ROLE (#351) 2024-07-14 20:44:03 +08:00
Dream Hunter
2c1e63b8bc feat: add USER_DEFAULT_ROLE (#350) 2024-07-14 20:38:55 +08:00
Dream Hunter
f3a1d980c5 fix: roleDonotExist tip (#349) 2024-07-14 20:09:21 +08:00
Dream Hunter
75c48beb3b feat: add USER_ROLES && admin pages search when keybord enter && auto trim (#348)
* feat: add USER_ROLES

* feat: admin pages search when keybord enter && auto trim

* feat: update version to v0.6.0
2024-07-14 19:57:43 +08:00
Dream Hunter
26ccfdd6e0 feat: only allow address [a-z0-9] (#347) 2024-07-13 19:03:54 +08:00
刘志聪
aa8f3b4d46 fix: remove useless sql (#342) 2024-07-10 01:06:00 +08:00
Dream Hunter
a749c829d2 feat: update docs (#340) 2024-07-08 19:09:37 +08:00
Dream Hunter
4b2caf1a4b feat: update docs (#339) 2024-07-08 19:02:14 +08:00
Dream Hunter
80a8848ed8 feat: remove apiV1 and tables && update admin/statistics (#337) 2024-07-08 12:33:43 +08:00
Dream Hunter
dcfc1b3721 Update CHANGELOG.md 2024-07-07 12:55:29 +08:00
Dream Hunter
b0a0a6a1ef feat: updage dependencies (#336) 2024-07-06 20:26:54 +08:00
Dream Hunter
00c671cf14 feat: logo click 5 time to admin page && fix: 401 cannot show auth modal (#335) 2024-07-06 20:21:21 +08:00
Dream Hunter
0b78d1ff4a Update CHANGELOG.md 2024-07-04 13:38:00 +08:00
Dream Hunter
d152a7ce9f feat: allow admin and user delete mail, sendbox, send access(only admin) (#331) 2024-07-04 13:31:33 +08:00
Dream Hunter
21fed3fb00 feat: allow admin and user delete mail, sendbox, send access(only admin) (#329) 2024-07-04 13:25:14 +08:00
Dream Hunter
9448b3c754 fix: sendVerificationCode do not check cfToken when no TurnstileSiteKey (#325) 2024-06-29 01:01:46 +08:00
Dream Hunter
f1827f223a feat: docs: github actions add FRONTEND_BRANCH (#324) 2024-06-28 23:10:35 +08:00
Dream Hunter
2a0a34869e feat: github actions add FRONTEND_BRANCH env (#323) 2024-06-28 23:04:08 +08:00
Dream Hunter
881e66e484 feat: add DOMAIN_LABELS for chinese domain label (#322) 2024-06-28 22:25:06 +08:00
Dream Hunter
de7c3d5176 Update README.md 2024-06-12 14:52:16 +08:00
Dream Hunter
720d097ed7 Update README.md 2024-06-12 14:51:32 +08:00
Dream Hunter
53a03dc6a0 Update README.md 2024-06-12 14:35:22 +08:00
Dream Hunter
72b99e0c5e feat: upgeade npm packages (#311) 2024-06-12 13:57:26 +08:00
Dream Hunter
c4d9fe1fb9 feat: docs: add new-address-api (#309) 2024-06-12 13:53:40 +08:00
Dream Hunter
af9f46ba65 fix: smtp imap proxy sever: support senbox v2 (#306) 2024-06-09 13:35:26 +08:00
Dream Hunter
8bfd76bf71 Update README.md 2024-06-07 00:03:47 +08:00
Dream Hunter
dd477fe2c8 Update CHANGELOG.md 2024-06-06 21:45:57 +08:00
Dream Hunter
0db611bb3e feat: add MIN_ADDRESS_LEN && MAX_ADDRESS_LEN (#304) 2024-06-06 21:44:22 +08:00
Dream Hunter
6225f6521a fix: parseMail tg bot (#302) 2024-06-04 22:51:28 +08:00
Dream Hunter
da2e72e523 feat: add mail-parser-wasm-worker (#301) 2024-06-04 21:57:42 +08:00
Dream Hunter
c5d01e09e8 feat: change version (#294) 2024-06-01 21:31:30 +08:00
Dream Hunter
201c7658be feat: UI: admin mail page style add margin-top: 10px (#293) 2024-06-01 21:27:45 +08:00
Dream Hunter
77155299e0 feat: add mailbox multi delete and download (#292) 2024-06-01 21:23:17 +08:00
Dream Hunter
9725407c77 feat: add s3 attachment (#291) 2024-06-01 20:08:42 +08:00
Dream Hunter
e91bbe273a feat: UI i18n depends on router (#290) 2024-06-01 12:13:44 +08:00
Dream Hunter
b792c196c1 feat: UI i18n depends on router (#289) 2024-06-01 12:12:13 +08:00
Dream Hunter
7a368d7b23 feat: add global forward address list (#288) 2024-05-31 23:21:12 +08:00
Dream Hunter
f882e4cf97 feat: add Local Address Manage (#285) 2024-05-29 13:40:02 +08:00
Dream Hunter
00abf79417 fix: cannot delete addres when not bind KV (#284) 2024-05-29 12:08:56 +08:00
Dream Hunter
1f8edbc295 feat: add TITLE in worker for custom website title (#276) 2024-05-26 16:21:27 +08:00
Dream Hunter
268f3d6446 Update CHANGELOG.md 2024-05-26 15:35:18 +08:00
Dream Hunter
8dc9d32a7e feat: add resend for send mail (#275) 2024-05-26 15:30:18 +08:00
Dream Hunter
3b6736924b feat: add resend for send mail (#274) 2024-05-26 12:37:11 +08:00
Dream Hunter
dc14338b69 fix: telegram bot golbalPush (#273) 2024-05-25 17:37:39 +08:00
Dream Hunter
954ae2dfb1 fix: telegram bot golbalPush (#272) 2024-05-25 14:38:33 +08:00
Dream Hunter
6d55acdd42 fix: telegram bot golbalPush (#271) 2024-05-25 14:34:16 +08:00
Dream Hunter
03bb210016 fix: telegram bot golbalPush (#270) 2024-05-25 14:20:34 +08:00
Dream Hunter
bf3c372d8c feat: telegram bot global push (#269) 2024-05-25 14:07:00 +08:00
Dream Hunter
9414f7a977 Update README.md 2024-05-25 11:53:23 +08:00
Dream Hunter
32440706d2 feat: add sendmail sunset in readme (#267) 2024-05-23 12:32:07 +08:00
Dream Hunter
c976664f4e feat: UI: lazy load (#266) 2024-05-23 12:23:43 +08:00
Dream Hunter
aa04dc4efa feat: smtp_proxy_server use httpx (#265) 2024-05-22 22:24:59 +08:00
Dream Hunter
02e3e755e7 feat: docs: Telegram Mini App (#264) 2024-05-22 20:57:30 +08:00
Dream Hunter
37ed2955ff fix: webhook JSON.stringify (#263) 2024-05-22 20:48:03 +08:00
Dream Hunter
dd49768cfc feat: smtp_proxy_server update package (#262) 2024-05-21 23:53:32 +08:00
Dream Hunter
9ec11f7040 fix: telegram bot/miniapp bugs (#261) 2024-05-21 22:45:48 +08:00
Dream Hunter
2533257b68 fix: telegram bot/miniapp bugs (#259) 2024-05-21 13:32:47 +08:00
Dream Hunter
96ea81e055 fix: telegram bot/miniapp bugs (#258) 2024-05-21 13:28:02 +08:00
Dream Hunter
8459e0c306 fix: telegram bot/miniapp bugs (#257) 2024-05-21 13:18:15 +08:00
Dream Hunter
91d7896e65 feat: telegram mini app open mail from bot (#256) 2024-05-21 02:03:06 +08:00
Dream Hunter
69771fc1d1 feat: telegram bot unbind && delete address (#254) 2024-05-20 13:23:41 +08:00
Dream Hunter
c00382259a fix: telegram mini app pipeline (#253) 2024-05-19 11:37:06 +08:00
Dream Hunter
8ac96bff1f fix: telegram mini app pipeline (#252) 2024-05-19 11:34:30 +08:00
Dream Hunter
9f3ff7b980 fix: telegram mini app (#251) 2024-05-19 11:32:57 +08:00
Dream Hunter
870b7b9198 feat: add telegram mini app (#250) 2024-05-19 00:35:10 +08:00
Dream Hunter
46576316e6 Update CHANGELOG.md 2024-05-18 17:08:41 +08:00
Dream Hunter
a5ff4f2d90 feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view (#248) 2024-05-18 17:02:21 +08:00
Dream Hunter
745e36f838 feat: UI changes (#247) 2024-05-18 14:46:24 +08:00
Dream Hunter
a351839408 fix build (#245) 2024-05-18 14:07:52 +08:00
Dream Hunter
ca00a877ad feat: telegram bot TelegramSettings && webhook (#244)
* feat: telegram bot TelegramSettings

* feat: webhook
2024-05-18 14:02:18 +08:00
Dream Hunter
53a06fc9d6 Update CHANGELOG.md 2024-05-17 00:12:14 +08:00
Dream Hunter
607c04c810 fix: smtp_proxy: update raise imap4.NoSuchMailbox (#243) 2024-05-17 00:06:43 +08:00
Dream Hunter
243dac976b fix: smtp_proxy: cannot decode 8bit && tg bot new random address (#242) 2024-05-16 18:18:16 +08:00
Dream Hunter
4bd876a5f4 feat: docs: Telegram Bot (#241) 2024-05-16 13:27:26 +08:00
Dream Hunter
bbc4c05d69 fix: remove cleanup address due to many table need to be clean (#240) 2024-05-16 13:11:29 +08:00
Dream Hunter
78badf2eaa feat: telegram bot (#238) 2024-05-16 12:57:23 +08:00
Dream Hunter
6bb6fa8298 feat: remove mailV1Alert && fix mobile showSideMargin (#236) 2024-05-14 14:44:47 +08:00
Dream Hunter
a5b5335137 feat: add about page (#235) 2024-05-14 13:25:27 +08:00
Dream Hunter
f2685f9830 Update README.md 2024-05-14 12:56:34 +08:00
Dream Hunter
45bc5cad9e Update README.md 2024-05-14 12:52:43 +08:00
Dream Hunter
ea4ce9bf63 feat: add page functions proxy to make response faster (#234) 2024-05-14 12:43:03 +08:00
Dream Hunter
9de2d23be1 feat: add version for frontend && backend (#230) 2024-05-12 18:31:43 +08:00
Dream Hunter
62bec9ef90 fix: Maintenance wrong label (#229) 2024-05-12 18:09:42 +08:00
Dream Hunter
edc110b6ac fix: imap server (#227) 2024-05-12 17:47:01 +08:00
Dream Hunter
3fc8bba234 Update CHANGELOG.md 2024-05-12 11:58:48 +08:00
Dream Hunter
4b9d40d04b feat: UI show version (#226) 2024-05-12 11:52:55 +08:00
Dream Hunter
af027fd75e feat: add imap proxy server (#225) 2024-05-12 11:34:52 +08:00
Dream Hunter
386441a743 fix: smtp_proxy_server support decode from mail charset (#223) 2024-05-10 23:08:38 +08:00
Dream Hunter
46e04fd94a fix: name max 30 && /external/api/send_mail not return result (#222) 2024-05-10 22:57:31 +08:00
Sunset Mikoto
cdc5c5202b fix: typos (#221) 2024-05-10 21:23:59 +08:00
Dream Hunter
58c3fdb5b4 feat: use common function handleListQuery when query by page (#220) 2024-05-09 23:31:13 +08:00
Dream Hunter
fc6b0246b1 Update CHANGELOG.md 2024-05-09 20:26:22 +08:00
Dream Hunter
98cd6d9fcc feat: docs (#218) 2024-05-09 20:23:52 +08:00
Dream Hunter
45783c7494 feat: UI move AdminContact to common (#217) 2024-05-09 20:14:29 +08:00
Dream Hunter
9bfded4d1d feat: UI check openSettings in Login page (#215) 2024-05-09 19:06:00 +08:00
Dream Hunter
b7308587c6 feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin (#214)
* fix: generateName multi dot && user jwt exp in 30 days

* feat: support admin search mailbox

* fix: DELETE mail bug(should be raw_mails)

* feat: UI add globalTabplacement

* feat: UI add useSideMargin option
2024-05-09 18:43:09 +08:00
Dream Hunter
1fa56dfe98 feat: add UserLogin (#209) 2024-05-08 23:14:44 +08:00
Dream Hunter
55b2603913 feat: add Deploy to Cloudflare Workers docs (#206) 2024-05-05 20:12:45 +08:00
Dream Hunter
7738210b93 feat: add Deploy to Cloudflare Workers button (#205) 2024-05-05 20:07:17 +08:00
Dream Hunter
9d84eb0634 feat: update docs (#204)
* feat: update docs

* feat: update docs
2024-05-05 17:45:02 +08:00
Dream Hunter
66a6d40499 Update CHANGELOG.md 2024-05-05 00:03:56 +08:00
Dream Hunter
41bed8b1db feat: add /external/api/send_mail for body verify (#202) 2024-05-04 23:52:06 +08:00
Dream Hunter
869bf99340 fix: delete account loading (#201) 2024-05-04 23:18:30 +08:00
Dream Hunter
f63c4ebd9c feat: add CF Turnstile when new address (#200) 2024-05-04 23:14:23 +08:00
Dream Hunter
26969bebb8 feat: update sendbox UI (#199) 2024-05-04 18:58:34 +08:00
Dream Hunter
1d191a091a Update README.md 2024-05-04 18:39:28 +08:00
Dream Hunter
4d6c4e2d10 feat: add sendBlockList (#198) 2024-05-04 18:37:28 +08:00
Dream Hunter
7f456078ea feat: quote content when reply message (#197) 2024-05-04 18:18:12 +08:00
Dream Hunter
68c18a6153 fix: admin/delete_address (#196) 2024-05-04 17:56:14 +08:00
Dream Hunter
2d01639ecd fix: admin/delete_address (#195) 2024-05-04 17:52:59 +08:00
Dream Hunter
53b7cfccde Update CHANGELOG.md 2024-05-04 00:16:15 +08:00
359 changed files with 37722 additions and 8036 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

@@ -0,0 +1,54 @@
---
name: version-upgrade
description: 升级项目版本号。当用户要求升级版本、更新版本号、发布新版本时使用此 skill。支持 major主版本、minor次版本、patch补丁版本三种升级方式。
---
# Version Upgrade
升级 cloudflare_temp_email 项目版本号。
## 需要修改的文件
1. `frontend/package.json` - version 字段
2. `worker/package.json` - version 字段
3. `worker/src/constants.ts` - VERSION 常量(格式:`VERSION: 'v' + '1.4.0'`
4. `pages/package.json` - version 字段
5. `vitepress-docs/package.json` - version 字段
6. `CHANGELOG.md` - 添加新版本占位符
7. `CHANGELOG_EN.md` - 添加新版本占位符(英文)
## 版本升级流程
1. 读取 `frontend/package.json` 获取当前版本号
2. 根据升级类型计算新版本号:
- major: 1.3.0 → 2.0.0
- minor: 1.3.0 → 1.4.0
- patch: 1.3.0 → 1.3.1
3. 更新所有 package.json 文件中的 version 字段
4. 在 CHANGELOG.md 顶部添加新版本占位符
5. 在 CHANGELOG_EN.md 顶部添加新版本占位符
## CHANGELOG 格式
中文 (CHANGELOG.md) - 在 `## v{OLD_VERSION}(main)` 之前插入:
```markdown
## v{VERSION}(main)
### Features
### Bug Fixes
### Improvements
```
英文 (CHANGELOG_EN.md) - 同样格式。
## 提交信息格式
```
feat: upgrade version to v{VERSION}
- Update version number to {VERSION} in all package.json files
- Add v{VERSION} placeholder in CHANGELOG.md
```

11
.dockerignore Normal file
View File

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

3
.flake8 Normal file
View File

@@ -0,0 +1,3 @@
[flake8]
max-line-length = 180
exclude = .git,__pycache__,build,dist

View File

@@ -0,0 +1,57 @@
diff --git a/worker/src/common.ts b/worker/src/common.ts
index 9b758f0..e2150b5 100644
--- a/worker/src/common.ts
+++ b/worker/src/common.ts
@@ -469,29 +469,29 @@ export const commonParseMail = async (parsedEmailContext: ParsedEmailContext): P
}
const raw_mail = parsedEmailContext.rawEmail;
// NOTE: WASM parse email
- // try {
- // const { parse_message_wrapper } = await import('mail-parser-wasm-worker');
-
- // const parsedEmail = parse_message_wrapper(raw_mail);
- // parsedEmailContext.parsedEmail = {
- // sender: parsedEmail.sender || "",
- // subject: parsedEmail.subject || "",
- // text: parsedEmail.text || "",
- // headers: parsedEmail.headers?.map(
- // (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 || "",
+ subject: parsedEmail.subject || "",
+ text: parsedEmail.text || "",
+ headers: parsedEmail.headers?.map(
+ (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 { default: PostalMime } = await import('postal-mime');
const parsedEmail = await PostalMime.parse(raw_mail);

View File

@@ -1,6 +1,9 @@
name: Deploy Backend Production
name: Deploy Backend
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
tags:
- "*"
@@ -13,32 +16,67 @@ 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: 18
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
if [ -n "$use_worker_assets_with_telegram" ]; then
echo "Building with telegram pages"
pnpm build:telegram:pages
else
echo "Building with normal pages"
pnpm build:pages
fi
cd ..
fi
export debug_mode=${{ secrets.DEBUG_MODE }}
export use_mail_wasm_parser=${{ secrets.BACKEND_USE_MAIL_WASM_PARSER }}
cd worker/
echo '${{ secrets.BACKEND_TOML }}' > wrangler.toml
# ✅ 修复核心:使用环境变量写入,避免 Shell 解析特殊字符
printf '%s\n' "$WRANGLER_TOML_CONTENT" > wrangler.toml
pnpm install --no-frozen-lockfile
output=$(pnpm run deploy 2>&1)
if [ $? -ne 0 ]; then
code=$?
echo "Command failed with exit code $code"
exit $code
if [ -n "$use_mail_wasm_parser" ]; then
echo "Using mail-parser-wasm-worker"
pnpm add mail-parser-wasm-worker
git apply ../.github/config/mail-parser-wasm-worker.patch
echo "Applied mail-parser-wasm-worker patch"
fi
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
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,22 +15,38 @@ 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: 18
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
run: |
for ((attempt=1; attempt<=10; attempt++)); do
if wget -q --spider "https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest/download/frontend.zip"; then
echo "frontend.zip found."
break
else
if [ $attempt -eq 10 ]; then
echo "Exceeded maximum retries. frontend.zip not found."
else
echo "frontend.zip not found. Retrying in 30 seconds..."
sleep 30
fi
fi
done
- name: Deploy Docs for ${{github.ref_name}}
run: |
cd vitepress-docs/

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

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

View File

@@ -1,43 +1,85 @@
name: Deploy Frontend
on:
workflow_run:
workflows: [Upstream Sync]
types: [completed]
push:
paths:
- "frontend/**"
tags:
- "*"
workflow_dispatch:
jobs:
deploy:
deploy-frontend:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 18
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 Frontend for ${{ github.ref_name }}
if: ${{ env.FRONTEND_NAME != '' }}
run: |
cd frontend/
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
export project_name=${{ secrets.FRONTEND_NAME }}
pnpm install --no-frozen-lockfile
pnpm run deploy --project-name=$project_name
echo "Deploying prodcution for ${{ github.ref_name }}"
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
if [ -n "$frontend_branch" ]; then
echo "Deploying branch $frontend_branch"
pnpm run deploy:actions --project-name=$FRONTEND_NAME --branch $frontend_branch
else
echo "Deploying branch production"
pnpm run deploy --project-name=$FRONTEND_NAME
fi
echo "Deployed for tag ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FRONTEND_NAME: ${{ secrets.FRONTEND_NAME }}
deploy-telegram-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Deploy Telegram Frontend for ${{ github.ref_name }}
if: ${{ env.TG_FRONTEND_NAME != '' }}
run: |
cd frontend/
echo "${{ secrets.FRONTEND_ENV }}" > .env.prod
pnpm install --no-frozen-lockfile
export frontend_branch="${{ secrets.FRONTEND_BRANCH }}"
if [ -n "$frontend_branch" ]; then
echo "Deploying telegram mini app branch $frontend_branch"
pnpm run deploy:actions:telegram --project-name=$TG_FRONTEND_NAME --branch $frontend_branch
else
echo "Deploying telegram mini app branch production"
pnpm run deploy:telegram --project-name=$TG_FRONTEND_NAME
fi
echo "Deployed telegram mini app for ${{ github.ref_name }}"
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
TG_FRONTEND_NAME: ${{ secrets.TG_FRONTEND_NAME }}

View File

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

27
.github/workflows/pr_agent.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Codium PR Agent
on:
pull_request:
types: [opened, reopened, ready_for_review]
issue_comment:
jobs:
pr_agent_job:
if: ${{ github.event.sender.type != 'Bot' }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
contents: write
name: Run pr agent on every pull request, respond to user comments
steps:
- name: PR Agent action step
id: pragent
uses: qodo-ai/pr-agent@main
env:
PR_REVIEWER.REQUIRE_TESTS_REVIEW: "false"
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
CONFIG.MODEL: "gpt-4o"
CONFIG.MODEL_TURBO: "gpt-4o"
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

25
.github/workflows/sync.yaml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Upstream Sync
on:
schedule:
- cron: "0 0 * * 1"
workflow_dispatch:
jobs:
sync_latest_from_upstream:
name: Sync latest commits from upstream repo
runs-on: ubuntu-latest
if: ${{ github.event.repository.fork }}
steps:
- uses: actions/checkout@v6
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
with:
upstream_sync_repo: dreamhunter2333/cloudflare_temp_email
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
test_mode: false

View File

@@ -6,31 +6,80 @@ on:
- "*"
jobs:
build:
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 18
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: Build Frontend
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:release
- name: Zip Frontend dist
run: cd frontend/dist/ && zip -r frontend.zip *
run: cd frontend/dist/ && zip -r frontend.zip * && mv frontend.zip ../
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: frontend
path: frontend/frontend.zip
build-telegram-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Build Telegram Frontend
run: cd frontend && pnpm install --no-frozen-lockfile && pnpm build:telegram:release
- name: Zip Telegram Frontend dist
run: cd frontend/dist/ && zip -r telegram-frontend.zip * && mv telegram-frontend.zip ../
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: telegram-frontend
path: frontend/telegram-frontend.zip
build-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: cp wrangler.toml
run: cd worker && cp wrangler.toml.template wrangler.toml
@@ -38,9 +87,47 @@ jobs:
- name: Build Backend
run: cd worker && pnpm install --no-frozen-lockfile && pnpm build
- name: Move worker.js
run: cd worker/dist && mv worker.js ../
- name: Build Worker with wasm mail parser
run: |
cd worker
echo "Using mail-parser-wasm-worker"
pnpm add mail-parser-wasm-worker
git apply ../.github/config/mail-parser-wasm-worker.patch
echo "Applied mail-parser-wasm-worker patch"
pnpm build
zip -r worker-with-wasm-mail-parser.zip dist/worker.js dist/*.wasm
- name: Upload worker.js
uses: actions/upload-artifact@v4
with:
name: worker-js
path: worker/worker.js
- name: Upload wasm worker
uses: actions/upload-artifact@v4
with:
name: worker-wasm
path: worker/worker-with-wasm-mail-parser.zip
release:
runs-on: ubuntu-latest
needs: [build-frontend, build-telegram-frontend, build-backend]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Upload to Release
uses: softprops/action-gh-release@v2
with:
files: |
frontend/dist/frontend.zip
worker/dist/worker.js
artifacts/frontend/frontend.zip
artifacts/telegram-frontend/telegram-frontend.zip
artifacts/worker-js/worker.js
artifacts/worker-wasm/worker-with-wasm-mail-parser.zip

141
.gitignore vendored
View File

@@ -1,3 +1,144 @@
.DS_Store
dist/
test/
.vscode/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.wrangler
wrangler.toml
.dev.vars
pnpm-lock.yaml
# E2E test artifacts
e2e/test-results/
e2e/playwright-report/
e2e/.e2e-pids

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"ms-python.vscode-pylance",
"1yib.rust-bundle",
"rust-lang.rust-analyzer",
"vue.volar"
]
}

View File

@@ -1,8 +1,503 @@
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
# CHANGE LOG
## main branch to be released
<p align="center">
<a href="CHANGELOG.md">🇨🇳 中文</a> |
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
</p>
## v1.5.0(main)
### Features
- feat: |自动回复| 发件人过滤支持正则表达式匹配,使用 `/pattern/` 语法(如 `/@example\.com$/`),同时保持前缀匹配的向后兼容
- feat: |Turnstile| 新增全局登录表单 Turnstile 人机验证,通过 `ENABLE_GLOBAL_TURNSTILE_CHECK` 环境变量控制(#767
- feat: |Telegram| Telegram 推送支持发送邮件附件(单文件限制 50MB多附件通过 `sendMediaGroup` 批量发送,通过 `ENABLE_TG_PUSH_ATTACHMENT` 环境变量开启(#894
### Bug Fixes
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
### Testing
- test: |E2E| 新增自动回复触发 E2E 测试,覆盖空前缀、前缀匹配、正则匹配和禁用状态场景
### Docs
- docs: |Telegram| 新增每用户邮件推送和全局推送功能说明文档(#769
- docs: |Webhook| 新增 Telegram Bot、企业微信、Discord 等常用推送平台的 Webhook 模板示例
- feat: |Webhook| 前端预设模板新增 Telegram Bot、企业微信、Discord 三个模板
### Improvements
## v1.4.0
### Features
- feat: |用户注册| 新增用户注册邮箱正则校验功能,管理员可配置邮箱格式验证规则
- feat: |前端| 新增可配置的 Status 菜单按钮,通过 `STATUS_URL` 环境变量配置状态监控页面链接
- feat: |SMTP| SMTP 代理服务支持 STARTTLS通过 `smtp_tls_cert``smtp_tls_key` 环境变量配置
- feat: |Webhook| Webhook 设置页面新增预设模板下拉菜单,支持 Message Pusher、Bark、ntfy 一键填充配置
### Bug Fixes
- fix: |Telegram| 修复 admin 用户通过 Telegram MiniApp 查看邮件时报 `Auth date expired` 的问题,支持 admin 密码认证查看邮件
- fix: |Admin API| 修复 `/admin/account_settings` 在未配置 KV 且 `fromBlockList` 为空时触发 `Cannot read properties of undefined (reading 'put')` 的问题
- fix: |数据库| 修复 `DB_INIT_QUERIES` 缺少 `idx_raw_mails_message_id` 索引导致 `UPDATE raw_mails ... WHERE message_id = ?` 全表扫描的问题,同步 `schema.sql` 与初始化代码,新增 v0.0.6 迁移逻辑
- fix: |文档| 修复 User Mail API 文档中错误使用 `x-admin-auth` 的问题,改为正确的 `x-user-token`
- fix: |前端| 修复暗色主题下邮件内容文字看不清的问题,优化纯文本邮件和 Shadow DOM 渲染的暗色模式样式
- docs: |文档| 新增 Admin 删除邮件、删除邮箱地址、清空收件箱、清空发件箱 API 文档
- fix: |前端| 修复回复 HTML 格式邮件时丢失原邮件 HTML 内容的问题,优先使用 HTML 原文而非纯文本
- fix: |安全| 修复回复/转发邮件时的 XSS 风险,使用 DOMPurify 对 HTML 内容进行白名单消毒,对纯文本内容进行 HTML 转义
- fix: |API| 修复 `requset_send_mail_access` API 路径拼写错误,改为 `request_send_mail_access`
### Testing
- test: |E2E| 新增 Docker 化端到端测试环境Playwright + Mailpit`cd e2e && npm test` 一条命令运行
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
### Improvements
- style: |邮件列表| 优化收件箱和发件箱空状态显示,根据邮件数量显示不同提示信息,添加语义化图标
- feat: |后台管理| 邮箱地址列表来源IP添加 ip.im 查询链接点击可快速查看IP信息
- docs: |文档| 修复 VitePress 中英文切换路径错误,改用双前缀 locale 配置
- feat: |IMAP 代理| 重构 IMAP 服务端拆分为独立模块HTTP 客户端、邮箱、消息),使用 `deferToThread` 异步 HTTP 避免阻塞 Twisted reactor使用后端 `id` 作为稳定 UID新增 STARTTLS 支持、LRU 消息缓存、session 级 flags 管理、SEARCH 命令支持、JWT 凭证和地址+密码双登录方式,新增完整测试套件
- fix: |IMAP 代理| 修复 `getHeaders()` 过滤逻辑、`store()` 崩溃问题
- fix: |邮件解析| 修复 `parse_email.py` 中使用私有属性 `_payload` 导致编码错误的问题,改用 `get_payload(decode=True)` 正确解码邮件体
## v1.3.0
### Features
- feat: |OAuth2| 新增 OAuth2 邮箱格式转换功能,支持通过正则表达式转换第三方登录返回的邮箱格式(如将 `user@domain` 转换为 `user@custom.domain`
- feat: |OAuth2| 新增 OAuth2 提供商 SVG 图标支持,管理员可为登录按钮配置自定义图标,预置 GitHub、Linux Do、Authentik 模板图标
- feat: |发送邮件| 未配置发送邮件功能时自动隐藏发送邮件 tab、发件箱 tab 和回复按钮
### Bug Fixes
- fix: |用户地址| 修复禁止匿名创建时,已登录用户地址数量限制检查失效的问题,新增公共函数 `isAddressCountLimitReached` 统一处理地址数量限制逻辑
### Improvements
- refactor: |代码重构| 提取地址数量限制检查为公共函数,优化代码复用性
- perf: |性能优化| GET 请求中的地址活动时间更新改为异步执行,使用 `waitUntil` 不阻塞响应
## v1.2.1
### Bug Fixes
- fix: |定时任务| 修复定时任务清理报错 `e.get is not a function`,使用可选链安全访问 Context 方法
### Improvements
- style: |AI 提取| 暗色模式下 AI 提取信息使用更柔和的蓝色 (#A8C7FA),减少视觉疲劳
## v1.2.0
### Breaking Changes
- |数据库| 新增 `source_meta` 字段,需执行 `db/2025-12-27-source-meta.sql` 更新数据库或到 admin 维护页面点击数据库更新按钮
### Features
- feat: |Admin| 新增管理员账号页面,显示当前登录方式并支持退出登录(仅限密码登录方式)
- fix: |GitHub Actions| 修复容器镜像名需要全部小写的问题
- feat: |邮件转发| 新增来源地址正则转发功能,支持按发件人地址过滤转发,完全向后兼容
- feat: |地址来源| 新增地址来源追踪功能记录地址创建来源Web 记录 IPTelegram 记录用户 IDAdmin 后台标记)
- feat: |邮件过滤| 移除后端 keyword 参数,改为前端过滤当前页邮件,优化查询性能
- feat: |前端| 地址切换统一为下拉组件,极简模式支持切换,主页提供地址管理入口
- feat: |数据库| 为 `message_id` 字段添加索引,优化邮件更新操作性能,需执行 `db/2025-12-15-message-id-index.sql` 更新数据库
- feat: |Admin| 维护页面增加自定义 SQL 清理功能,支持定时任务执行自定义清理语句
- feat: |国际化| 后端 API 错误消息全面支持中英文国际化
- feat: |Telegram| 机器人支持中英文切换,新增 `/lang` 命令设置语言偏好
## v1.1.0
- feat: |AI 提取| 增加 AI 邮件识别功能,使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- 支持优先级提取:验证码 > 认证链接 > 服务链接 > 订阅链接 > 其他链接
- 管理员可配置地址白名单(支持通配符,如 `*@example.com`
- 前端列表和详情页展示提取结果
- 需要配置 `ENABLE_AI_EMAIL_EXTRACT` 环境变量和 AI 绑定
- 需要执行 `db/2025-12-06-metadata.sql` 文件中的 SQL 更新 `D1` 数据库 或者到 admin维护页面点击数据库更新按钮
- feat: |Admin| 维护页面增加清理 n 天前空邮件的邮箱地址功能
- fix: 修复自定义认证密码功能异常的问题 (前端属性名错误 & /open_api 接口被拦截)
## v1.0.7
- feat: |Admin| 新增 IP 黑名单功能,用于限制访问频率较高的 API
- feat: |Admin| 新增 ASN 组织黑名单功能,支持基于 ASN 组织名称过滤请求(支持文本匹配和正则表达式)
- feat: |Admin| 新增浏览器指纹黑名单功能,支持基于浏览器指纹过滤请求(支持精确匹配和正则表达式)
## v1.0.6
- feat: |DB| update db schema add index
- feat: |地址密码| 增加地址密码登录功能, 通过 `ENABLE_ADDRESS_PASSWORD` 配置启用, 需要执行 `db/2025-09-23-patch.sql` 文件中的 SQL 更新 `D1` 数据库
- fix: |GitHub Actions| 修复 debug 模式配置,仅当 DEBUG_MODE 为 'true' 时才启用调试模式
- feat: |Admin| 账户管理页面新增多选批量操作功能(批量删除、批量清空收件箱、批量清空发件箱)
- feat: |Admin| 维护页面增加清理未绑定用户地址的功能
- feat: 支持针对角色配置不同的绑定地址数量上限, 可在 admin 页面配置
## v1.0.5
- feat: 新增 `DISABLE_CUSTOM_ADDRESS_NAME` 配置: 禁用自定义邮箱地址名称功能
- feat: 新增 `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` 配置: 创建地址时优先使用第一个域名
- feat: |UI| 主页增加进入极简模式按钮
- feat: |Webhook| 增加白名单开关功能,支持灵活控制访问权限
## v1.0.4
- feat: |UI| 优化极简模式主页, 增加全部邮件页面功能(删除/下载/附件/...), 可在 `外观` 中切换
- feat: admin 账号设置页面增加 `邮件转发规则` 配置
- feat: admin 账号设置页面增加 `禁止接收未知地址邮件` 配置
- feat: 邮件页面增加 上一封/下一封 按钮
## v1.0.3
- fix: 修复 github actions 部署问题
- feat: telegram /new 不指定域名时, 使用随机地址
## v1.0.2
- fix: 修复 oauth2 登录失败的问题
## v1.0.1
- feat: |UI| 增加极简模式主页, 可在 `外观` 中切换
- fix: 修复 oauth2 登录时default role 不生效的问题
## v1.0.0
- fix: |UI| 修复 User 查看收件箱,不选择地址时,关键词查询不生效
- fix: 修复自动清理任务,时间为 0 时不生效的问题
- feat: 清理功能增加 创建 n 天前地址清理n 天前未活跃地址清理
- fix: |IMAP Proxy| 修复 IMAP Proxy 服务器,无法查看新邮件的问题
## v0.10.0
- feat: 支持 User 查看收件箱,`/user_api/mails` 接口, 支持 `address``keyword` 过滤
- fix: 修复 Oauth2 登录获取 Token 时,一些 Oauth2 需要 `redirect_uri` 参数的问题
- feat: 用户访问网页时,如果 `user token` 在 7 天内过期,自动刷新
- feat: admin portal 中增加初始化 db 的功能
- feat: 增加 `ALWAYS_SHOW_ANNOUNCEMENT` 变量,用于配置是否总是显示公告
## v0.9.1
- feat: |UI| support google ads
- feat: |UI| 使用 shadow DOM 防止样式污染
- feat: |UI| 支持 URL jwt 参数自动登录邮箱jwt 参数会覆盖浏览器中的 jwt
- fix: |CleanUP| 修复清理邮件时,清理时间超过 30 天报错的 bug
- feat: admin 用户管理页面: 增加 用户地址查看功能
- feat: | S3 附件| 增加 S3 附件删除功能
- feat: | Admin API| 增加 admin 绑定用户和地址的 api
- feat: | Oauth2 | Oatuh2 获取用户信息时,支持 `JSONPATH` 表达式
## v0.9.0
- feat: | Worker | 支持多语言
- feat: | Worker | `NO_LIMIT_SEND_ROLE` 配置支持多角色, 逗号分割
- feat: | Actions | build 里增加 `worker-with-wasm-mail-parser.zip` 支持 UI 部署带 `wasm` 的 worker
## v0.8.7
- fix: |UI| 修复移动设备日期显示问题
- feat: |Worker| 支持通过 `SMTP` 发送邮件, 使用 [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
## v0.8.6
- feat: |UI| 公告支持 html 格式
- feat: |UI| `COPYRIGHT` 支持 html 格式
- feat: |Doc| 优化部署文档,补充了 `Github Actions 部署文档`,增加了 `Worker 变量说明`
## v0.8.5
- feat: |mail-parser-wasm-worker| 修复 `initSync` 函数调用时的 `deprecated` 参数警告
- feat: rpc headers covert & typo (#559)
- fix: telegram mail page use iframe show email (#561)
- feat: |Worker| 增加 `REMOVE_ALL_ATTACHMENT``REMOVE_EXCEED_SIZE_ATTACHMENT` 用于移除邮件附件,由于是解析邮件的一些信息会丢失,比如图片等.
## v0.8.4
- fix: |UI| 修复 admin portal 无收件人邮箱删除调用api 错误
- feat: |Telegram Bot| 增加 telegram bot 清理无效地址凭证命令
- feat: 增加 worker 配置 `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` 禁用匿名用户创建邮箱地址,只允许登录用户创建邮箱地址
- feat: 增加 worker 配置 `ENABLE_ANOTHER_WORKER``ANOTHER_WORKER_LIST` ,用于调用其他 worker 的 rpc 接口 (#547)
- feat: |UI| 自动刷新配置保存到浏览器,可配置刷新间隔
- feat: 垃圾邮件检测增加存在时才检查的列表 `JUNK_MAIL_CHECK_LIST` 配置
- feat: | Worker | 增加 `ParsedEmailContext` 类用于缓存解析后的邮件内容,减少解析次数
- feat: |Github Action| Worker 部署增加 `DEBUG_MODE` 输出日志, `BACKEND_USE_MAIL_WASM_PARSER` 配置是否使用 wasm 解析邮件
## v0.8.3
- feat: |Github Action| 增加自动更新并部署功能
- feat: |UI| admin 用户设置,支持 oauth2 配置的删除
- feat: 增加垃圾邮件检测必须通过的列表 `JUNK_MAIL_FORCE_PASS_LIST` 配置
## v0.8.2
- fix: |Doc| 修复文档中的一些错误
- fix: |Github Action| 修复 frontend 部署分支错误的问题
- feat: admin 发送邮件功能
- feat: admin 后台,账号配置页面添加无限发送邮件的地址列表
## v0.8.1
- feat: |Doc| 更新 UI 安装的文档
- feat: |UI| 对用户隐藏邮箱账号的 ID
- feat: |UI| 增加邮件详情页的 `转发` 按钮
## v0.8.0
- feat: |UI| 随机生成地址时不超过最大长度
- feat: |UI| 邮件时间显示浏览器时区,可在设置中切换显示为 UTC 时间
- feat: 支持转移邮件到其他用户
## v0.7.6
### Breaking Changes
UI 部署 worker 需要点击 Settings -> Runtime, 修改 Compatibility flags, 增加 `nodejs_compat`
![worker-runtime](vitepress-docs/docs/public/ui_install/worker-runtime.png)
### Changes
- feat: 支持提前设置 bot info, 降低 telegram 回调延迟 (#441)
- feat: 增加 telegram mini app 的 build 压缩包
- feat: 增加是否启用垃圾邮件检查 `ENABLE_CHECK_JUNK_MAIL` 配置
## v0.7.5
- fix: 修复 `name` 的校验检查
## v0.7.4
- feat: UI 列表页面增加最小宽度
- fix: 修复 `name` 的校验检查
- fix: 修复 `DEFAULT_DOMAINS` 配置为空不生效的问题
## v0.7.3
- feat: worker 增加 `ADDRESS_CHECK_REGEX`, address name 的正则表达式, 只用于检查,符合条件将通过检查
- fix: UI 修复登录页面 tab 激活图标错位
- fix: UI 修复 admin 页面刷新弹框输入密码的问题
- feat: support `Oath2` 登录, 可以通过 `Github` `Authentik` 等第三方登录, 详情查看 [OAuth2 第三方登录](https://temp-mail-docs.awsl.uk/zh/guide/feature/user-oauth2.html)
## v0.7.2
### Breaking Changes
`webhook` 的结构增加了 `enabled` 字段,已经配置了的需要重新在页面开启并保存。
### Changes
- fix: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 加载失败的问题
- feat: worker 增加 `# ADDRESS_REGEX = "[^a-z.0-9]"` 配置, 替换非法符号的正则表达式,如果不设置,默认为 [^a-z0-9], 需谨慎使用, 有些符号可能导致无法收件
- feat: worker 优化 webhook 逻辑, 支持 admin 配置全局 webhook, 添加 `message pusher` 集成示例
## v0.7.1
- fix: 修复用户角色加载失败的问题
- feat: admin 账号设置增加来源邮件地址黑名单配置
## v0.7.0
### Breaking Changes
DB changes: 增加用户 `passkey` 表, 需要执行 `db/2024-08-10-patch.sql` 更新 `D1` 数据库
### Changes
- Docs: Update new-address-api.md (#360)
- feat: worker 增加 `ADMIN_USER_ROLE` 配置, 用于配置管理员用户角色,此角色的用户可访问 admin 管理页面 (#363)
- feat: worker 增加 `DISABLE_SHOW_GITHUB` 配置, 用于配置是否显示 github 链接
- feat: worker 增加 `NO_LIMIT_SEND_ROLE` 配置, 用于配置可以无限发送邮件的角色
- feat: 用户增加 `passkey` 登录方式, 用于用户登录, 无需输入密码
- feat: worker 增加 `DISABLE_ADMIN_PASSWORD_CHECK` 配置, 用于配置是否禁用 admin 控制台密码检查, 若你的网站只可私人访问,可通过此禁用检查
## v0.6.1
- pages github actions && 修复清理邮件天数为 0 不生效 by @tqjason (#355)
- fix: imap proxy server 不支持 密码 by @dreamhunter2333 (#356)
- worker 新增 `ANNOUNCEMENT` 配置, 用于配置公告信息 by @dreamhunter2333 (#357)
- fix: telegram bot 新建地址默认选择第一个域名 by @dreamhunter2333 (#358)
## v0.6.0
### Breaking Changes
DB changes: 增加用户角色表, 需要执行 `db/2024-07-14-patch.sql` 更新 `D1` 数据库
### Changes
worker 配置文件新增 `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, 具体查看文档 [worker配置](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html#%E4%BF%AE%E6%94%B9-wrangler-toml-%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6)
- 移除 `apiV1` 相关代码和相关的数据库表
- 更新 `admin/statistics` api, 添加用户统计信息
- 更新地址的规则,只允许小写+数字,对于历史的地址在查询邮件时会进行 `lowercase` 处理
- 增加用户角色功能,`admin` 可以设置用户角色(目前可配置每个角色域名和前缀)
- admin 页面搜索优化, 回车自动搜索, 输入内容自动 trim
## v0.5.4
- 点击 logo 5 次进入 admin 页面
- 修复 401 时无法跳转登录页面(admin 和 网站认证)
## v0.5.3
- 修复 smtp imap proxy sever 的一些 bug
- 完善用户/admin 删除收件箱/发件箱的功能
- admin 可以删除 发件权限记录
- 添加中文邮件别名配置 `DOMAIN_LABELS` [文档](https://temp-mail-docs.awsl.uk/zh/guide/cli/worker.html)
- 移除 `mail channels` 相关代码
- github actions 增加 `FRONTEND_BRANCH` 变量用于指定部署的分支 (#324)
## v0.5.1
- 添加 `mail-parser-wasm-worker` 用于 worker 解析邮件, [文档](https://temp-mail-docs.awsl.uk/zh/guide/feature/mail_parser_wasm_worker.html)
- 添加校验用户邮箱长度配置 `MIN_ADDRESS_LEN``MAX_ADDRESS_LEN`
- 修复 `pages function` 未转发 `telegram` api 问题
## v0.5.0
- UI: 增加本地缓存进行地址管理
- worker: 增加 `FORWARD_ADDRESS_LIST` 全局邮件转发地址(等同于 `catch all`)
- UI: 多语言使用路由进行切换
- 添加保存附件到 S3 的功能
- UI: 增加收取邮件列表 `批量删除``批量下载`
## v0.4.6
- worker 配置文件添加 `TITLE = "Custom Title"`, 可自定义网站标题
- 修复 KV 未绑定无法删除地址的问题
## v0.4.5
- UI lazy load 懒加载
- telegram bot 添加用户全局推送功能(admin 用户)
- 增加对 cloudflare verified 用户发送邮件
- 增加使用 `resend` 发送邮件, `resend` 提供 http 和 smtp api, 使用更加方便, 文档: https://temp-mail-docs.awsl.uk/zh/guide/config-send-mail.html
## v0.4.4
- 增加 telegram mini app
- telegram bot 增加 `ubind`, `delete` 指令
- 修复 webhook 多行文本的问题
## v0.4.3
### Breaking Changes
配置文件 `main = "src/worker.js"` 改为 `main = "src/worker.ts"`
### Changes
- `telegram bot` 白名单配置
- `ENABLE_WEBHOOK` 添加 webhook
- UI: admin 页面使用双层 tab
- UI: 登录后可直接主页切换地址
- UI: 发件箱也采用左右分栏显示(类似收件箱)
- `SMTP IMAP Proxy` 添加发件箱查看
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
## v0.4.2
- 修复 smtp imap proxy sever 的一些 bug
- 修复 UI 界面文字错误, 界面增加版本号
- 增加 telegram bot 文档 https://temp-mail-docs.awsl.uk/zh/guide/feature/telegram.html
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
### v0.4.1
- 用户名限制最长30个字符
- 修复 `/external/api/send_mail` 未返回的 bug (#222)
- 添加 `IMAP proxy` 服务,支持 `IMAP` 查看邮件
- UI 界面增加版本号显示
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
### New Contributors
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
## v0.4.0
### DB Changes/Breaking changes
新增 user 相关表,用于存储用户信息
- `db/2024-05-08-patch.sql`
### config changs
启用用户注册邮箱验证需要 `KV`
```toml
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
```
### function changs
- 增加用户注册功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证
- 增加默认以文本显示邮件文本和HTML邮箱显示方式切换按钮
- 修复 `BUG` 随机生成的邮箱名字不合法 #211
- `admin` 邮件页面支持邮件内容搜索 #210
- 修复删除地址时邮件未删除的BUG #213
- UI 增加全局标签页位置配置, 侧边距配置
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
## v0.3.3
- 修复 Admin 删除邮件报错
- UI: 回复邮件按钮, 引用原始邮件文本 #186
- 添加发送邮件地址黑名单
- 添加 `CF Turnstile` 人机验证配置
- 添加 `/external/api/send_mail` 发送邮件 api, 使用 body 验证 #194
## v0.3.2
## What's Changed
- UI: 添加回复邮件按钮
- 添加定时清理功能,可在 admin 页面配置(需要在配置文件启用定时任务)
- 修复删除账户无反应的问题
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
## v0.3.1
@@ -86,7 +581,6 @@ set
- 添加 RATE_LIMITER 限流 发送邮件 和 新建地址
- 一些 bug 修复
---
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
- feat: requset_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
@@ -142,7 +636,7 @@ The `mails` table will be discarded, and the `raw` text of the new `mail` will b
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
pnpm run deploy
cd ../frontend
pnpm run deploy

667
CHANGELOG_EN.md Normal file
View File

@@ -0,0 +1,667 @@
<!-- markdownlint-disable-file MD004 MD024 MD033 MD034 MD036 -->
# CHANGE LOG
<p align="center">
<a href="CHANGELOG.md">🇨🇳 中文</a> |
<a href="CHANGELOG_EN.md">🇺🇸 English</a>
</p>
## v1.5.0(main)
### Features
- 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)
### 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 auto-reply trigger E2E tests covering empty prefix, prefix matching, regex matching, and disabled state
### Docs
- docs: |Telegram| Add per-user mail push and global push documentation (#769)
- docs: |Webhook| Add webhook template examples for Telegram Bot, WeChat Work, Discord and other common push platforms
- feat: |Webhook| Add Telegram Bot, WeChat Work, Discord preset templates to frontend webhook settings
### Improvements
## v1.4.0
### Features
- feat: |User Registration| Add email regex validation for user registration, admins can configure email format validation rules
- feat: |Frontend| Add configurable Status menu button via `STATUS_URL` environment variable for status monitoring page link
- feat: |SMTP| Add STARTTLS support for SMTP proxy server via `smtp_tls_cert` and `smtp_tls_key` environment variables
- feat: |Webhook| Add preset templates dropdown to Webhook settings page, supporting one-click fill for Message Pusher, Bark, and ntfy
### Bug Fixes
- fix: |Telegram| Fix admin users unable to view emails via Telegram MiniApp due to `Auth date expired` error, support admin password auth for viewing emails
- fix: |Admin API| Fix `/admin/account_settings` throwing `Cannot read properties of undefined (reading 'put')` when KV is not configured and `fromBlockList` is empty
- fix: |Database| Fix missing `idx_raw_mails_message_id` index in `DB_INIT_QUERIES` causing full table scan on `UPDATE raw_mails ... WHERE message_id = ?`, sync `schema.sql` with init code, add v0.0.6 migration
- fix: |Docs| Fix User Mail API documentation incorrectly using `x-admin-auth`, changed to correct `x-user-token`
- fix: |Frontend| Fix email content text being unreadable in dark theme, improve dark mode styles for plain text mail and Shadow DOM rendering
- docs: |Docs| Add Admin API documentation for delete mail, delete address, clear inbox, and clear sent items
- fix: |Frontend| Fix reply to HTML email losing original HTML content, prefer HTML message over plain text
- fix: |Security| Fix XSS vulnerability in reply/forward mail content, sanitize HTML with DOMPurify whitelist and escape plain text
- fix: |API| Fix typo in `requset_send_mail_access` API path, renamed to `request_send_mail_access`
### Testing
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
### Improvements
- style: |Mail List| Improve empty state display for inbox and sent box, show different messages based on mail count, add semantic icons
- feat: |Admin| Add ip.im lookup link for source IP in address list, click to quickly view IP information
- docs: |Docs| Fix VitePress i18n language switch path error, use dual-prefix locale configuration
- feat: |IMAP Proxy| Refactor IMAP server into separate modules (HTTP client, mailbox, message), use `deferToThread` for async HTTP to avoid blocking Twisted reactor, use backend `id` as stable UID, add STARTTLS support, LRU message cache, session-local flags management, SEARCH command support, JWT credential and address+password dual login methods, and comprehensive test suite
- fix: |IMAP Proxy| Fix `getHeaders()` filtering and `store()` crash
- fix: |Email Parser| Fix `parse_email.py` using private `_payload` attribute causing encoding errors, use `get_payload(decode=True)` for proper email body decoding
## v1.3.0
### Features
- feat: |OAuth2| Add email format transformation support for OAuth2, allowing regex-based email format conversion from third-party login providers (e.g., transform `user@domain` to `user@custom.domain`)
- feat: |OAuth2| Add SVG icon support for OAuth2 providers, admins can configure custom icons for login buttons, preset icons for GitHub, Linux Do, Authentik templates
- feat: |Send Mail| Auto-hide sendmail tab, sendbox tab, and reply button when send mail is not configured
### Bug Fixes
- fix: |User Address| Fix address count limit check failure when anonymous creation is disabled for logged-in users, add public function `isAddressCountLimitReached` to unify address count limit logic
### Improvements
- refactor: |Code Refactoring| Extract address count limit check as a public function to improve code reusability
- perf: |Performance| Change address activity time update in GET requests to async execution using `waitUntil`, non-blocking response
## v1.2.1
### Bug Fixes
- fix: |Scheduled Tasks| Fix scheduled task cleanup error `e.get is not a function`, use optional chaining for safe access to Context methods
### Improvements
- style: |AI Extraction| Use softer blue color (#A8C7FA) for AI extraction info in dark mode to reduce eye strain
## v1.2.0
### Breaking Changes
- |Database| Add `source_meta` field, need to execute `db/2025-12-27-source-meta.sql` to update database or click database update button on admin maintenance page
### Features
- feat: |Admin| Add admin account page, display current login method and support logout (password login only)
- fix: |GitHub Actions| Fix container image name must be lowercase
- feat: |Email Forwarding| Add source address regex forwarding, filter by sender address, fully backward compatible
- feat: |Address Source| Add address source tracking feature, record address creation source (Web records IP, Telegram records user ID, Admin panel marked)
- feat: |Email Filtering| Remove backend keyword parameter, switch to frontend filtering of current page emails, optimize query performance
- feat: |Frontend| Unify address switching into a dropdown component, support switching in simple mode, add address management entry on the homepage
- feat: |Database| Add index for `message_id` field to optimize email update operations, need to execute `db/2025-12-15-message-id-index.sql` to update database
- feat: |Admin| Add custom SQL cleanup feature to maintenance page, support scheduled task execution of custom cleanup statements
- feat: |i18n| Backend API error messages now fully support Chinese and English internationalization
- feat: |Telegram| Bot supports Chinese/English switching, add `/lang` command to set language preference
## v1.1.0
- feat: |AI Extraction| Add AI email recognition feature, use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
- Support priority extraction: verification codes > authentication links > service links > subscription links > other links
- Admin can configure address whitelist (supports wildcards, e.g. `*@example.com`)
- Frontend list and detail pages display extraction results
- Need to configure `ENABLE_AI_EMAIL_EXTRACT` environment variable and AI binding
- Need to execute SQL in `db/2025-12-06-metadata.sql` file to update `D1` database or click database update button on admin maintenance page
- feat: |Admin| Add feature to cleanup addresses with empty mailboxes older than n days on maintenance page
- fix: Fix custom authentication password function issue (frontend property name error & /open_api interface blocked)
## v1.0.7
- feat: |Admin| Add IP blacklist feature for limiting high-frequency API access
- feat: |Admin| Add ASN organization blacklist feature, support filtering requests based on ASN organization name (supports text matching and regex)
- feat: |Admin| Add browser fingerprint blacklist feature, support filtering requests based on browser fingerprint (supports exact matching and regex)
## v1.0.6
- feat: |DB| Update db schema add index
- feat: |Address Password| Add address password login feature, enabled via `ENABLE_ADDRESS_PASSWORD` configuration, need to execute SQL in `db/2025-09-23-patch.sql` file to update `D1` database
- fix: |GitHub Actions| Fix debug mode configuration, only enable debug mode when DEBUG_MODE is 'true'
- feat: |Admin| Account management page adds multi-select batch operations (batch delete, batch clear inbox, batch clear outbox)
- feat: |Admin| Maintenance page adds feature to cleanup unbound user addresses
- feat: Support configuring different bound address quantity limits for different roles, configurable in admin page
## v1.0.5
- feat: Add `DISABLE_CUSTOM_ADDRESS_NAME` configuration: disable custom email address name feature
- feat: Add `CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST` configuration: prioritize first domain when creating addresses
- feat: |UI| Add button to enter minimalist mode on homepage
- feat: |Webhook| Add whitelist switch feature, support flexible access control
## v1.0.4
- feat: |UI| Optimize minimalist mode homepage, add all emails page functionality (delete/download/attachments/...), switchable in `Appearance`
- feat: Admin account settings page adds `Email Forwarding Rules` configuration
- feat: Admin account settings page adds `Reject Unknown Address Emails` configuration
- feat: Email page adds Previous/Next buttons
## v1.0.3
- fix: Fix github actions deployment issue
- feat: telegram /new when domain not specified, use random address
## v1.0.2
- fix: Fix oauth2 login failure issue
## v1.0.1
- feat: |UI| Add minimalist mode homepage, switchable in `Appearance`
- fix: Fix oauth2 login default role not taking effect issue
## v1.0.0
- fix: |UI| Fix User inbox viewing, when address not selected, keyword query not working
- fix: Fix auto cleanup task, time 0 not taking effect issue
- feat: Cleanup feature adds cleanup of addresses created n days ago, cleanup of addresses inactive for n days
- fix: |IMAP Proxy| Fix IMAP Proxy server unable to view new emails issue
## v0.10.0
- feat: Support User inbox viewing, `/user_api/mails` interface, support `address` and `keyword` filtering
- fix: Fix Oauth2 login token retrieval, some Oauth2 require `redirect_uri` parameter issue
- feat: When user accesses webpage, if `user token` expires within 7 days, auto refresh
- feat: Add db initialization feature to admin portal
- feat: Add `ALWAYS_SHOW_ANNOUNCEMENT` variable to configure whether to always show announcements
## v0.9.1
- feat: |UI| Support google ads
- feat: |UI| Use shadow DOM to prevent style pollution
- feat: |UI| Support URL jwt parameter auto-login to mailbox, jwt parameter overrides browser jwt
- fix: |CleanUP| Fix cleanup emails when cleanup time exceeds 30 days error bug
- feat: Admin user management page: add user address viewing feature
- feat: | S3 Attachments| Add S3 attachment deletion feature
- feat: | Admin API| Add admin bind user and address api
- feat: | Oauth2 | When Oauth2 gets user info, support `JSONPATH` expressions
## v0.9.0
- feat: | Worker | Support multi-language
- feat: | Worker | `NO_LIMIT_SEND_ROLE` configuration supports multiple roles, comma separated
- feat: | Actions | Add `worker-with-wasm-mail-parser.zip` in build to support UI deployment with `wasm` worker
## v0.8.7
- fix: |UI| Fix mobile device date display issue
- feat: |Worker| Support sending emails via `SMTP`, using [zou-yu/worker-mailer](https://github.com/zou-yu/worker-mailer/blob/main/README_zh-CN.md)
## v0.8.6
- feat: |UI| Announcements support html format
- feat: |UI| `COPYRIGHT` supports html format
- feat: |Doc| Optimize deployment documentation, supplement `Github Actions Deployment Documentation`, add `Worker Variable Description`
## v0.8.5
- feat: |mail-parser-wasm-worker| Fix `deprecated` parameter warning when calling `initSync` function
- feat: rpc headers convert & typo (#559)
- fix: telegram mail page use iframe show email (#561)
- feat: |Worker| Add `REMOVE_ALL_ATTACHMENT` and `REMOVE_EXCEED_SIZE_ATTACHMENT` for removing email attachments, due to parsing emails some information will be lost, such as images.
## v0.8.4
- fix: |UI| Fix admin portal delete call api error when no recipient email
- feat: |Telegram Bot| Add telegram bot cleanup invalid address credentials command
- feat: Add worker configuration `DISABLE_ANONYMOUS_USER_CREATE_EMAIL` to disable anonymous user email creation, only allow logged-in users to create email addresses
- feat: Add worker configuration `ENABLE_ANOTHER_WORKER` and `ANOTHER_WORKER_LIST`, for calling other worker rpc interfaces (#547)
- feat: |UI| Auto refresh configuration saved to browser, configurable refresh interval
- feat: Spam detection adds check-when-exists list `JUNK_MAIL_CHECK_LIST` configuration
- feat: | Worker | Add `ParsedEmailContext` class for caching parsed email content, reduce parsing times
- feat: |Github Action| Worker deployment adds `DEBUG_MODE` output logging, `BACKEND_USE_MAIL_WASM_PARSER` configuration for whether to use wasm to parse emails
## v0.8.3
- feat: |Github Action| Add auto update and deploy feature
- feat: |UI| Admin user settings, support oauth2 configuration deletion
- feat: Add spam detection must-pass list `JUNK_MAIL_FORCE_PASS_LIST` configuration
## v0.8.2
- fix: |Doc| Fix some documentation errors
- fix: |Github Action| Fix frontend deployment branch error issue
- feat: Admin send email feature
- feat: Admin backend, account configuration page adds unlimited send email address list
## v0.8.1
- feat: |Doc| Update UI installation documentation
- feat: |UI| Hide mailbox account ID from users
- feat: |UI| Add `Forward` button to email detail page
## v0.8.0
- feat: |UI| Random address generation doesn't exceed max length
- feat: |UI| Email time display in browser timezone, can switch to display UTC time in settings
- feat: Support transferring emails to other users
## v0.7.6
### Breaking Changes
UI deployment worker needs to click Settings -> Runtime, modify Compatibility flags, add `nodejs_compat`
![worker-runtime](vitepress-docs/docs/public/ui_install/worker-runtime.png)
### Changes
- feat: Support pre-setting bot info to reduce telegram callback latency (#441)
- feat: Add telegram mini app build archive
- feat: Add whether to enable spam check `ENABLE_CHECK_JUNK_MAIL` configuration
## v0.7.5
- fix: Fix `name` validation check
## v0.7.4
- feat: UI list page adds minimum width
- fix: Fix `name` validation check
- fix: Fix `DEFAULT_DOMAINS` configuration empty not taking effect issue
## v0.7.3
- feat: Worker adds `ADDRESS_CHECK_REGEX`, address name regex, only for checking, matching will pass check
- fix: UI fix login page tab active icon misalignment
- fix: UI fix admin page refresh popup password input issue
- feat: Support `OAuth2` login, can login via `Github` `Authentik` and other third parties, see details [OAuth2 Third-party Login](https://temp-mail-docs.awsl.uk/en/guide/feature/user-oauth2.html)
## v0.7.2
### Breaking Changes
`webhook` structure adds `enabled` field, existing configurations need to be re-enabled and saved on the page.
### Changes
- fix: Worker adds `NO_LIMIT_SEND_ROLE` configuration, loading failure issue
- feat: Worker adds `# ADDRESS_REGEX = "[^a-z.0-9]"` configuration, regex for replacing illegal symbols, if not set, defaults to [^a-z0-9], use with caution, some symbols may cause receiving issues
- feat: Worker optimizes webhook logic, supports admin configuring global webhook, adds `message pusher` integration example
## v0.7.1
- fix: Fix user role loading failure issue
- feat: Admin account settings adds source email address blacklist configuration
## v0.7.0
### Breaking Changes
DB changes: Add user `passkey` table, need to execute `db/2024-08-10-patch.sql` to update `D1` database
### Changes
- Docs: Update new-address-api.md (#360)
- feat: Worker adds `ADMIN_USER_ROLE` configuration, for configuring admin user role, users with this role can access admin management page (#363)
- feat: Worker adds `DISABLE_SHOW_GITHUB` configuration, for configuring whether to show github link
- feat: Worker adds `NO_LIMIT_SEND_ROLE` configuration, for configuring roles that can send unlimited emails
- feat: User adds `passkey` login method, for user login, no password required
- feat: Worker adds `DISABLE_ADMIN_PASSWORD_CHECK` configuration, for configuring whether to disable admin console password check, if your site is only privately accessible, you can disable the check
## v0.6.1
- pages github actions && fix cleanup emails days 0 not taking effect by @tqjason (#355)
- fix: imap proxy server doesn't support password by @dreamhunter2333 (#356)
- worker adds `ANNOUNCEMENT` configuration, for configuring announcement info by @dreamhunter2333 (#357)
- fix: telegram bot create new address defaults to first domain by @dreamhunter2333 (#358)
## v0.6.0
### Breaking Changes
DB changes: Add user role table, need to execute `db/2024-07-14-patch.sql` to update `D1` database
### Changes
Worker configuration file adds `DEFAULT_DOMAINS`, `USER_ROLES`, `USER_DEFAULT_ROLE`, see documentation [worker configuration](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
- Remove `apiV1` related code and related database tables
- Update `admin/statistics` api, add user statistics info
- Update address rules, only allow lowercase+numbers, for historical addresses `lowercase` processing will be performed when querying emails
- Add user role feature, `admin` can set user roles (currently can configure domain and prefix for each role)
- Admin page search optimization, enter key auto search, input content auto trim
## v0.5.4
- Click logo 5 times to enter admin page
- Fix 401 cannot redirect to login page (admin and site authentication)
## v0.5.3
- Fix some bugs in smtp imap proxy server
- Improve user/admin delete inbox/outbox functionality
- Admin can delete send permission records
- Add Chinese email alias configuration `DOMAIN_LABELS` [documentation](https://temp-mail-docs.awsl.uk/en/guide/cli/worker.html)
- Remove `mail channels` related code
- github actions adds `FRONTEND_BRANCH` variable to specify deployment branch (#324)
## v0.5.1
- Add `mail-parser-wasm-worker` for worker email parsing, [documentation](https://temp-mail-docs.awsl.uk/en/guide/feature/mail_parser_wasm_worker.html)
- Add user email length validation configuration `MIN_ADDRESS_LEN` and `MAX_ADDRESS_LEN`
- Fix `pages function` not forwarding `telegram` api issue
## v0.5.0
- UI: Add local cache for address management
- worker: Add `FORWARD_ADDRESS_LIST` global email forwarding address (equivalent to `catch all`)
- UI: Multi-language uses routing for switching
- Add save attachments to S3 feature
- UI: Add received email list `batch delete` and `batch download`
## v0.4.6
- Worker configuration file adds `TITLE = "Custom Title"`, can customize website title
- Fix KV not bound unable to delete address issue
## v0.4.5
- UI lazy load
- telegram bot adds user global push feature (admin users)
- Add support for cloudflare verified user sending emails
- Add using `resend` to send emails, `resend` provides http and smtp api, easier to use, documentation: https://temp-mail-docs.awsl.uk/en/guide/config-send-mail.html
## v0.4.4
- Add telegram mini app
- telegram bot adds `unbind`, `delete` commands
- Fix webhook multiline text issue
## v0.4.3
### Breaking Changes
Configuration file `main = "src/worker.js"` changed to `main = "src/worker.ts"`
### Changes
- `telegram bot` whitelist configuration
- `ENABLE_WEBHOOK` add webhook
- UI: admin page uses two-level tabs
- UI: can directly switch addresses on homepage after login
- UI: outbox also uses split view display (similar to inbox)
- `SMTP IMAP Proxy` add outbox viewing
* feat: telegram bot TelegramSettings && webhook by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/244
* fix build by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/245
* feat: UI changes by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/247
* feat: SMTP IMAP Proxy: add sendbox && UI: sendbox use split view by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/248
## v0.4.2
- Fix some bugs in smtp imap proxy server
- Fix UI interface text errors, interface adds version number
- Add telegram bot documentation https://temp-mail-docs.awsl.uk/en/guide/feature/telegram.html
* fix: imap server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/227
* fix: Maintenance wrong label by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/229
* feat: add version for frontend && backend by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/230
* feat: add page functions proxy to make response faster by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/234
* feat: add about page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/235
* feat: remove mailV1Alert && fix mobile showSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/236
* feat: telegram bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/238
* fix: remove cleanup address due to many table need to be clean by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/240
* feat: docs: Telegram Bot by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/241
* fix: smtp_proxy: cannot decode 8bit && tg bot new random address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/242
* fix: smtp_proxy: update raise imap4.NoSuchMailbox by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/243
### v0.4.1
- Username limited to max 30 characters
- Fix `/external/api/send_mail` not returning bug (#222)
- Add `IMAP proxy` service, support `IMAP` viewing emails
- UI interface adds version number display
* feat: use common function handleListQuery when query by page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/220
* fix: typos by @lwd-temp in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
* fix: name max 30 && /external/api/send_mail not return result by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/222
* fix: smtp_proxy_server support decode from mail charset by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/223
* feat: add imap proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/225
* feat: UI show version by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/226
### New Contributors
* @lwd-temp made their first contribution in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/221
## v0.4.0
### DB Changes/Breaking changes
Added user related tables for storing user information
- `db/2024-05-08-patch.sql`
### config changes
Enable user registration email verification requires `KV`
```toml
# kv config for send email verification code
# [[kv_namespaces]]
# binding = "KV"
# id = "xxxx"
```
### function changes
- Add user registration feature, can bind email addresses, automatically obtain email JWT credentials after binding
- Add default text display for emails, text and HTML email display mode switch button
- Fix `BUG` randomly generated email names are invalid #211
- `admin` email page supports email content search #210
- Fix bug where emails weren't deleted when deleting addresses #213
- UI adds global tab position configuration, side margin configuration
* feat: update docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/204
* feat: add Deploy to Cloudflare Workers button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/205
* feat: add Deploy to Cloudflare Workers docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/206
* feat: add UserLogin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/209
* feat: admin search mailbox && fix generateName multi dot && user jwt exp in 30 days && UI globalTabplacement && useSideMargin by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/214
* feat: UI check openSettings in Login page by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/215
* feat: UI move AdminContact to common by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/217
* feat: docs by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/218
## v0.3.3
- Fix Admin delete email error
- UI: Reply email button, quote original email text #186
- Add send email address blacklist
- Add `CF Turnstile` CAPTCHA configuration
- Add `/external/api/send_mail` send email api, use body verification #194
## v0.3.2
## What's Changed
- UI: Add reply email button
- Add scheduled cleanup feature, configurable in admin page (need to enable scheduled task in config file)
- Fix delete account no response issue
* feat: UI: MailBox add reply button by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/187
* feat: add cron auto clean up by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/189
* fix: delete account by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/190
## v0.3.1
### DB Changes
Added `settings` table for storing general configuration information
- `db/2024-05-01-patch.sql`
### Changes
- `ENABLE_USER_CREATE_EMAIL` whether to allow users to create emails
- Allow admin to create emails without prefix
- Add `SMTP proxy server`, support SMTP sending emails
- Fix some cases where browsers can't load `wasm` use js to parse emails
- Footer adds `COPYRIGHT`
- UI allows users to switch email display mode `v-html` / `iframe`
- Add `admin` account configuration page, support configuring user registration name blacklist
* feat: support admin create address && add ENABLE_USER_CREATE_EMAIL co… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/175
* feat: add SMTP proxy server by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/177
* fix: cf ui var is string by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/178
* fix: UI mailbox 100vh to 80vh by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/179
* fix: smtp_proxy_server hostname && add docker image for linux/arm64 by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/180
* fix: some browser do not support wasm by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/182
* feat: add COPYRIGHT by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/183
* feat: UI: add user page: useIframeShowMail && mailboxSplitSize by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/184
* feat: add address_block_list for new address by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/185
## v0.3.0
### Breaking Changes
The prefix of the `address` table will migrate from code to db, please replace `tmp` in the sql below with your prefix, then execute.
If your data is important, please backup your database first.
**Note: Replace prefix**
```sql
update
address
set
name = 'tmp' || name;
```
### Changes
- Migrate the prefix of the `address` table from code to db
- `admin` account page adds send/receive email counts
- `admin` outbox page defaults to show all
- `admin` send permission page supports search by address
- `admin` email page uses split view UI
* feat: remove PREFIX logic in db by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/171
* feat: admin page add account mail count && sendbox default all && sen… by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/172
* feat: all mail use MailBox Component by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/173
**Full Changelog**: https://github.com/dreamhunter2333/cloudflare_temp_email/compare/0.2.10...v0.3.0
## v0.2.10
- `ENABLE_USER_DELETE_EMAIL` whether to allow users to delete account and emails
- `ENABLE_AUTO_REPLY` whether to enable auto reply
- fetchAddressError prompt improvement
- Auto refresh shows countdown
* feat: docs update by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/165
* feat: add ENABLE_USER_DELETE_EMAIL && ENABLE_AUTO_REPLY && modify fetchAddressError i18n && UI: show autoRefreshInterval by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/169
## v0.2.9
- Add rich text editor
- Admin contact info, won't show if not configured, can configure any string `ADMIN_CONTACT = "xx@xx.xxx"`
- Default send email balance, if not set, will be 0 `DEFAULT_SEND_BALANCE = 1`
## v0.2.8
- Allow users to delete emails
- Admin notifies user by email when modifying send permissions
- Send permission defaults to 1
- Add RATE_LIMITER rate limiting for sending emails and creating new addresses
- Some bug fixes
- feat: allow user delete mail && notify when send access changed by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/132
- feat: request_send_mail_access default 1 balance by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/143
- fix: RATE_LIMITER not call jwt by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/146
- fix: delete_address not delete address_sender by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/153
- fix: send_balance not update when click sendmail by @dreamhunter2333 in https://github.com/dreamhunter2333/cloudflare_temp_email/pull/155
## v0.2.7
- Added user interface installation documentation
- Support email DKIM
- Rate limiting configuration for `/api/new_address`
## v0.2.6
- Added admin query outbox page
- Add admin data cleaning page
## 2024-04-12 v0.2.5
- Support send email
DB changes:
- `db/2024-04-12-patch.sql`
## 2024-04-10 v0.2.0
### Breaking Changes
- remove `ENABLE_ATTACHMENT` config
- use rust wasm to parse email in frontend
- deprecated api moved to `/api/v1`
### Rust Mail Parser
Due to some problems with nodejs' email parsing library, this version switches to using rust wasm to call rust's mail parsing library.
- Faster speed, good attachment support, can display attachment images of emails
- Parsing supports more rfc specifications
### DB changes
The `mails` table will be discarded, and the `raw` text of the new `mail` will be directly stored in the `raw_mails` table
## Upgrade Step
```bash
git checkout v0.2.0
cd worker
wrangler d1 execute dev --file=../db/2024-04-09-patch.sql --remote
pnpm run deploy
cd ../frontend
pnpm run deploy
```
Note: For historical messages, use the Deploy New web page to view old data.
```bash
git checkout feature/backup
cd frontend
# Create a new pages for accessing old data
pnpm run deploy --project-name temp-email-v1
```
## 2024-04-09 v0.0.0
release v0.0.0
## 2024-04-03
DB changes
- `db/2024-04-03-patch.sql`
Changes:
- add delete account
- add admin panel search
## 2024-01-13
DB changes
- `db/2024-01-13-patch.sql`

103
CLAUDE.md Normal file
View File

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

204
README.md
View File

@@ -1,49 +1,197 @@
# 使用 cloudflare 免费服务,搭建临时邮箱
<!-- markdownlint-disable-file MD033 MD045 -->
# Cloudflare 临时邮箱 - 免费搭建临时邮件服务
## [查看部署文档](https://temp-mail-docs.awsl.uk)
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
</a>
</p>
## [English Docs](https://temp-mail-docs.awsl.uk/en/)
<p align="center">
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="FeaturedHelloGitHub" height="30"/>
</a>
</p>
## [CHANGELOG](CHANGELOG.md)
<p align="center">
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
</p>
## [在线演示](https://mail.awsl.uk/)
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
**一个功能完整的临时邮箱服务!**
- **完全免费** - 基于 Cloudflare 免费服务构建,零成本运行
- **高性能** - Rust WASM 邮件解析,响应速度极快
- **现代化界面** - 响应式设计,支持多语言,操作简便
- **地址密码** - 支持为邮箱地址设置独立密码,增强安全性 (通过 `ENABLE_ADDRESS_PASSWORD` 启用)
## 部署文档 - 快速开始
[部署文档](https://temp-mail-docs.awsl.uk) | [Github Action 部署文档](https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html)
<a href="https://temp-mail-docs.awsl.uk/zh/guide/actions/github-action.html">
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
</a>
## 更新日志
查看 [CHANGELOG](CHANGELOG.md) 了解最新更新内容。
## 在线体验
立即体验 → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<details>
<summary>服务状态监控(点击收缩/展开)</summary>
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Backend](https://temp-email-api.awsl.uk/) | [![Deploy Backend Production](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | [![Deploy Frontend](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |
</details>
<details>
<summary>Star History点击收缩/展开)</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>
- [使用 cloudflare 免费服务,搭建临时邮箱](#使用-cloudflare-免费服务搭建临时邮箱)
- [查看部署文档](#查看部署文档)
- [English Docs](#english-docs)
- [CHANGELOG](#changelog)
- [在线演示](#在线演示)
- [功能/TODO](#功能todo)
- [Reference](#reference)
</details>
## 功能/TODO
<details open>
<summary>目录(点击收缩/展开)</summary>
- [x] 使用 `password` 重新登录之前的邮箱
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
- [x] 支持多语言
- [x] 增加访问密码,可作为私人站点
- [x] 增加自动回复功能
- [x] 增加查看 `附件` 功能
- [x] 使用 `rust wasm` 解析邮件
- [x] 支持发送邮件
- [x] 支持 `DKIM`
- [Cloudflare 临时邮箱 - 免费搭建临时邮件服务](#cloudflare-临时邮箱---免费搭建临时邮件服务)
- [部署文档 - 快速开始](#部署文档---快速开始)
- [更新日志](#更新日志)
- [在线体验](#在线体验)
- [核心功能](#核心功能)
- [邮件处理](#邮件处理)
- [用户管理](#用户管理)
- [管理功能](#管理功能)
- [多语言与界面](#多语言与界面)
- [集成与扩展](#集成与扩展)
- [技术架构](#技术架构)
- [系统架构](#系统架构)
- [技术栈](#技术栈)
- [主要组件](#主要组件)
- [加入社区](#加入社区)
</details>
## 核心功能
<details open>
<summary>核心功能详情(点击收缩/展开)</summary>
### 邮件处理
- [x] 使用 `rust wasm` 解析邮件解析速度快几乎所有邮件都能解析node 的解析模块解析邮件失败的邮件rust wasm 也能解析成功
- [x] **AI 邮件识别** - 使用 Cloudflare Workers AI 自动提取邮件中的验证码、认证链接、服务链接等重要信息
- [x] 支持发送邮件,支持 `DKIM` 验证
- [x] 支持 `SMTP``Resend` 等多种发送方式
- [x] 增加查看 `附件` 功能,支持附件图片显示
- [x] 支持 S3 附件存储和删除功能
- [x] 垃圾邮件检测和黑白名单配置
- [x] 邮件转发功能,支持全局转发地址
### 用户管理
- [x] 使用 `凭证` 重新登录之前的邮箱
- [x] 添加完整的用户注册登录功能可绑定邮箱地址绑定后可自动获取邮箱JWT凭证切换不同邮箱
- [x] 支持 `OAuth2` 第三方登录Github、Authentik 等)
- [x] 支持 `Passkey` 无密码登录
- [x] 用户角色管理,支持多角色域名和前缀配置
- [x] 用户收件箱查看,支持地址和关键词过滤
### 管理功能
- [x] 完整的 admin 控制台
- [x] `admin` 后台创建无前缀邮箱
- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件
- [x] admin 用户管理页面,增加用户地址查看功能
- [x] 定时清理功能,支持多种清理策略
- [x] 获取自定义名字的邮箱,`admin` 可配置黑名单
- [x] 增加访问密码,可作为私人站点
## Reference
### 多语言与界面
- Cloudflare D1 作为数据库
- 使用 Cloudflare Pages 部署前端
- 使用 Cloudflare Workers 部署后端
- email 转发使用 Cloudflare Email Routing
- [x] 前后台均支持多语言
- [x] 现代化 UI 设计,支持响应式布局
- [x] 支持 Google Ads 集成
- [x] 使用 shadow DOM 防止样式污染
- [x] 支持 URL JWT 参数自动登录
### 集成与扩展
- [x] 完整的 `Telegram Bot` 支持,以及 `Telegram` 推送Telegram Bot 小程序
- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件,`IMAP` 查看邮件
- [x] Webhook 支持,消息推送集成
- [x] 支持 `CF Turnstile` 人机验证
- [x] 限流配置,防止滥用
</details>
## 技术架构
<details>
<summary>技术架构详情(点击收缩/展开)</summary>
### 系统架构
- **数据库**: Cloudflare D1 作为主数据库
- **前端部署**: 使用 Cloudflare Pages 部署前端
- **后端部署**: 使用 Cloudflare Workers 部署后端
- **邮件转发**: 使用 Cloudflare Email Routing
### 技术栈
- **前端**: Vue 3 + Vite + TypeScript
- **后端**: TypeScript + Cloudflare Workers
- **邮件解析**: Rust WASM (mail-parser-wasm)
- **数据库**: Cloudflare D1 (SQLite)
- **存储**: Cloudflare KV + R2 (可选 S3)
- **代理服务**: Python SMTP/IMAP Proxy Server
### 主要组件
- **Worker**: 核心后端服务
- **Frontend**: Vue 3 用户界面
- **Mail Parser WASM**: Rust 邮件解析模块
- **SMTP Proxy Server**: Python 邮件代理服务
- **Pages Functions**: Cloudflare Pages 中间件
- **Documentation**: VitePress 文档站点
</details>
### 提醒
- 在Resend添加域名记录时如果您域名解析服务商正在托管您的3级域名a.b.com请删除Resend生成的默认name中二级域名前缀b否则将会添加a.b.b.com导致验证失败。添加记录后可通过
```bash
nslookup -qt="mx" a.b.com 1.1.1.1
```
进行验证。
## 加入社区
- [Telegram](https://t.me/cloudflare_temp_email)

196
README_EN.md Normal file
View File

@@ -0,0 +1,196 @@
<!-- markdownlint-disable-file MD033 MD045 -->
# Cloudflare Temp Email - Free Temporary Email Service
<p align="center">
<a href="https://temp-mail-docs.awsl.uk" target="_blank">
<img alt="docs" src="https://img.shields.io/badge/docs-grey?logo=vitepress">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/releases/latest" target="_blank">
<img src="https://img.shields.io/github/v/release/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE" target="_blank">
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors" target="_blank">
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img src="https://img.shields.io/github/last-commit/dreamhunter2333/cloudflare_temp_email">
</a>
</p>
<p align="center">
<a href="https://hellogithub.com/repository/2ccc64bb1ba346b480625f584aa19eb1" target="_blank">
<img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=2ccc64bb1ba346b480625f584aa19eb1&claim_uid=FxNypXK7UQ9OECT" alt="FeaturedHelloGitHub" height="30"/>
</a>
</p>
<p align="center">
<a href="README.md">中文文档</a> |
<a href="README_EN.md">English Document</a>
</p>
> This project is for learning and personal use only. Please do not use it for any illegal activities, or you will be responsible for the consequences.
**A fully-featured temporary email service!**
- **Completely Free** - Built on Cloudflare's free services with zero cost
- **High Performance** - Rust WASM email parsing for extremely fast response
- **Modern UI** - Responsive design with multi-language support and easy operation
- **Address Password** - Support setting individual passwords for email addresses to enhance security (enabled via `ENABLE_ADDRESS_PASSWORD`)
## Deployment Documentation - Quick Start
[Documentation](https://temp-mail-docs.awsl.uk) | [Github Action Deployment Guide](https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html)
<a href="https://temp-mail-docs.awsl.uk/en/guide/actions/github-action.html">
<img src="https://deploy.workers.cloudflare.com/button" alt="Deploy to Cloudflare Workers" height="32">
</a>
## Changelog
See [CHANGELOG](CHANGELOG.md) for the latest updates.
## Live Demo
Try it now → [https://mail.awsl.uk/](https://mail.awsl.uk/)
<details>
<summary>Service Status Monitoring (Click to expand/collapse)</summary>
| | |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Backend](https://temp-email-api.awsl.uk/) | [![Deploy Backend Production](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/backend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/10/status) ![](https://uptime.aks.awsl.icu/api/badge/10/uptime) ![](https://uptime.aks.awsl.icu/api/badge/10/ping) ![](https://uptime.aks.awsl.icu/api/badge/10/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/10/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/10/response) |
| [Frontend](https://mail.awsl.uk/) | [![Deploy Frontend](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml/badge.svg)](https://github.com/dreamhunter2333/cloudflare_temp_email/actions/workflows/frontend_deploy.yaml) ![](https://uptime.aks.awsl.icu/api/badge/12/status) ![](https://uptime.aks.awsl.icu/api/badge/12/uptime) ![](https://uptime.aks.awsl.icu/api/badge/12/ping) ![](https://uptime.aks.awsl.icu/api/badge/12/avg-response) ![](https://uptime.aks.awsl.icu/api/badge/12/cert-exp) ![](https://uptime.aks.awsl.icu/api/badge/12/response) |
</details>
<details>
<summary>Star History (Click to expand/collapse)</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=dreamhunter2333/cloudflare_temp_email&type=Date" />
</picture>
</details>
<details open>
<summary>Table of Contents (Click to expand/collapse)</summary>
- [Cloudflare Temp Email - Free Temporary Email Service](#cloudflare-temp-email---free-temporary-email-service)
- [Deployment Documentation - Quick Start](#deployment-documentation---quick-start)
- [Changelog](#changelog)
- [Live Demo](#live-demo)
- [Core Features](#core-features)
- [Email Processing](#email-processing)
- [User Management](#user-management)
- [Admin Features](#admin-features)
- [Multi-language \& Interface](#multi-language--interface)
- [Integration \& Extensions](#integration--extensions)
- [Technical Architecture](#technical-architecture)
- [System Architecture](#system-architecture)
- [Tech Stack](#tech-stack)
- [Main Components](#main-components)
- [Join the Community](#join-the-community)
</details>
## Core Features
<details open>
<summary>Core Features Details (Click to expand/collapse)</summary>
### Email Processing
- [x] Use `rust wasm` to parse emails, with fast parsing speed. Almost all emails can be parsed. Even emails that Node.js parsing modules fail to parse can be successfully parsed by rust wasm
- [x] **AI Email Recognition** - Use Cloudflare Workers AI to automatically extract verification codes, authentication links, service links and other important information from emails
- [x] Support sending emails with `DKIM` verification
- [x] Support multiple sending methods such as `SMTP` and `Resend`
- [x] Add attachment viewing feature with support for displaying attachment images
- [x] Support S3 attachment storage and deletion
- [x] Spam detection and blacklist/whitelist configuration
- [x] Email forwarding feature with global forwarding address support
### User Management
- [x] Use `credentials` to log in to previously used mailboxes
- [x] Add complete user registration and login functionality. Users can bind email addresses and automatically obtain email JWT credentials to switch between different mailboxes after binding
- [x] Support `OAuth2` third-party login (Github, Authentik, etc.)
- [x] Support `Passkey` passwordless login
- [x] User role management with support for multi-role domain and prefix configuration
- [x] User inbox viewing with address and keyword filtering support
### Admin Features
- [x] Complete admin console
- [x] Create mailboxes without prefix in `admin` backend
- [x] Admin user management page with user address viewing feature
- [x] Scheduled cleanup function with support for multiple cleanup strategies
- [x] Get mailboxes with custom names, `admin` can configure blacklist
- [x] Add access password for use as a private site
### Multi-language & Interface
- [x] Both frontend and backend support multi-language
- [x] Modern UI design with responsive layout
- [x] Google Ads integration support
- [x] Use shadow DOM to prevent style pollution
- [x] Support URL JWT parameter auto-login
### Integration & Extensions
- [x] Complete `Telegram Bot` support, `Telegram` push notifications, and Telegram Bot mini app
- [x] Add `SMTP proxy server` supporting `SMTP` for sending emails and `IMAP` for viewing emails
- [x] Webhook support and message push integration
- [x] Support `CF Turnstile` CAPTCHA verification
- [x] Rate limiting configuration to prevent abuse
</details>
## Technical Architecture
<details>
<summary>Technical Architecture Details (Click to expand/collapse)</summary>
### System Architecture
- **Database**: Cloudflare D1 as the main database
- **Frontend Deployment**: Deploy frontend using Cloudflare Pages
- **Backend Deployment**: Deploy backend using Cloudflare Workers
- **Email Routing**: Use Cloudflare Email Routing
### Tech Stack
- **Frontend**: Vue 3 + Vite + TypeScript
- **Backend**: TypeScript + Cloudflare Workers
- **Email Parsing**: Rust WASM (mail-parser-wasm)
- **Database**: Cloudflare D1 (SQLite)
- **Storage**: Cloudflare KV + R2 (optional S3)
- **Proxy Service**: Python SMTP/IMAP Proxy Server
### Main Components
- **Worker**: Core backend service
- **Frontend**: Vue 3 user interface
- **Mail Parser WASM**: Rust email parsing module
- **SMTP Proxy Server**: Python email proxy service
- **Pages Functions**: Cloudflare Pages middleware
- **Documentation**: VitePress documentation site
</details>
### Important Notes
- When adding domain records in Resend, if your DNS provider is hosting your 3rd level domain a.b.com, please remove the 2nd level domain prefix b from the default name generated by Resend, otherwise it will add a.b.b.com, causing verification to fail. After adding the record, you can verify it using:
```bash
nslookup -qt="mx" a.b.com 1.1.1.1
```
## Join the Community
- [Telegram](https://t.me/cloudflare_temp_email)

21
db/2024-05-08-patch.sql Normal file
View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);

9
db/2024-07-14-patch.sql Normal file
View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL,
role_text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);

14
db/2024-08-10-patch.sql Normal file
View File

@@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);

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

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

View File

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

View File

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

View File

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

View File

@@ -1,35 +1,36 @@
CREATE TABLE IF NOT EXISTS mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
source TEXT,
address TEXT,
subject TEXT,
message TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mails_address ON mails(address);
CREATE TABLE IF NOT EXISTS raw_mails (
id INTEGER PRIMARY KEY,
message_id TEXT,
source TEXT,
address TEXT,
raw TEXT,
metadata TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_raw_mails_address ON raw_mails(address);
CREATE INDEX IF NOT EXISTS idx_raw_mails_created_at ON raw_mails(created_at);
CREATE INDEX IF NOT EXISTS idx_raw_mails_message_id ON raw_mails(message_id);
CREATE TABLE IF NOT EXISTS address (
id INTEGER PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
password TEXT,
source_meta TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_address_name ON address(name);
CREATE INDEX IF NOT EXISTS idx_address_created_at ON address(created_at);
CREATE INDEX IF NOT EXISTS idx_address_updated_at ON address(updated_at);
CREATE INDEX IF NOT EXISTS idx_address_source_meta ON address(source_meta);
CREATE TABLE IF NOT EXISTS auto_reply_mails (
id INTEGER PRIMARY KEY,
source_prefix TEXT,
@@ -43,15 +44,6 @@ CREATE TABLE IF NOT EXISTS auto_reply_mails (
CREATE INDEX IF NOT EXISTS idx_auto_reply_mails_address ON auto_reply_mails(address);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
source TEXT,
address TEXT,
message_id TEXT,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS address_sender (
id INTEGER PRIMARY KEY,
address TEXT UNIQUE,
@@ -71,9 +63,58 @@ CREATE TABLE IF NOT EXISTS sendbox (
CREATE INDEX IF NOT EXISTS idx_sendbox_address ON sendbox(address);
CREATE INDEX IF NOT EXISTS idx_sendbox_created_at ON sendbox(created_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
user_email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
user_info TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_user_email ON users(user_email);
CREATE TABLE IF NOT EXISTS users_address (
id INTEGER PRIMARY KEY,
user_id INTEGER,
address_id INTEGER UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_address_user_id ON users_address(user_id);
CREATE INDEX IF NOT EXISTS idx_users_address_address_id ON users_address(address_id);
CREATE TABLE IF NOT EXISTS user_roles (
id INTEGER PRIMARY KEY,
user_id INTEGER UNIQUE NOT NULL,
role_text TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id);
CREATE TABLE IF NOT EXISTS user_passkeys (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
passkey_name TEXT NOT NULL,
passkey_id TEXT NOT NULL,
passkey TEXT NOT NULL,
counter INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);

13
e2e/Dockerfile.e2e Normal file
View File

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

47
e2e/Dockerfile.frontend Normal file
View File

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

18
e2e/Dockerfile.worker Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@10.10.0 --activate
WORKDIR /app/worker
COPY worker/package.json worker/pnpm-lock.yaml ./
COPY worker/patches/ patches/
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
COPY worker/src/ src/
COPY worker/tsconfig.json ./
COPY e2e/fixtures/wrangler.toml.e2e wrangler.toml
EXPOSE 8787
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]

57
e2e/README.md Normal file
View File

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

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

@@ -0,0 +1,96 @@
services:
mailpit:
image: axllent/mailpit:v1.29
ports:
- "1025:1025"
- "8025:8025"
worker:
build:
context: ..
dockerfile: e2e/Dockerfile.worker
ports:
- "8787:8787"
depends_on:
- mailpit
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:8787/health_check"]
interval: 3s
timeout: 5s
start_period: 10s
retries: 20
frontend:
build:
context: ..
dockerfile: e2e/Dockerfile.frontend
ports:
- "5173:5173"
depends_on:
worker:
condition: service_healthy
smtp-proxy:
build:
context: ../smtp_proxy_server
dockerfile: dockerfile
ports:
- "11025:8025"
- "11143:11143"
environment:
PROXY_URL: http://worker:8787
PORT: "8025"
IMAP_PORT: "11143"
depends_on:
worker:
condition: service_healthy
smtp-proxy-tls:
build:
context: ../smtp_proxy_server
dockerfile: dockerfile
ports:
- "11026:8026"
- "11144:11144"
environment:
PROXY_URL: http://worker:8787
PORT: "8026"
IMAP_PORT: "11144"
smtp_tls_cert: /certs/cert.pem
smtp_tls_key: /certs/key.pem
imap_tls_cert: /certs/cert.pem
imap_tls_key: /certs/key.pem
entrypoint: ["/bin/bash", "/e2e-scripts/smtp-tls-entrypoint.sh"]
volumes:
- ./scripts:/e2e-scripts:ro
depends_on:
worker:
condition: service_healthy
e2e-runner:
build:
context: ..
dockerfile: e2e/Dockerfile.e2e
environment:
WORKER_URL: http://worker:8787
FRONTEND_URL: https://frontend:5173
MAILPIT_API: http://mailpit:8025/api
SMTP_PROXY_HOST: smtp-proxy
SMTP_PROXY_SMTP_PORT: "8025"
SMTP_PROXY_IMAP_PORT: "11143"
SMTP_PROXY_TLS_HOST: smtp-proxy-tls
SMTP_PROXY_TLS_SMTP_PORT: "8026"
SMTP_PROXY_TLS_IMAP_PORT: "11144"
CI: "true"
depends_on:
worker:
condition: service_healthy
frontend:
condition: service_started
smtp-proxy:
condition: service_started
smtp-proxy-tls:
condition: service_started
volumes:
- ./test-results:/app/e2e/test-results
- ./playwright-report:/app/e2e/playwright-report

View File

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

View File

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

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

@@ -0,0 +1,410 @@
{
"name": "cloudflare-temp-email-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^1.2.12",
"nodemailer": "^8.0.1"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.12.tgz",
"integrity": "sha512-UX8qCKXZk2xExe/x8KPTSbhROdtUGP13bSLSjT9Sb3YwGuryD4aFNlGhbWBW5B1GtgHMRxVv9yvl61RqXgIQtQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "8.0.1",
"pino": "10.3.1",
"socks": "2.8.7"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

19
e2e/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "cloudflare-temp-email-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
"test:down": "docker compose down -v"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@types/nodemailer": "^7.0.11",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
},
"dependencies": {
"imapflow": "^1.2.12",
"nodemailer": "^8.0.1"
}
}

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

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, requestSendAccess } from '../../fixtures/test-helpers';
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');
expect(address).toContain('@' + TEST_DOMAIN);
expect(jwt).toBeTruthy();
// Request send access (creates address_sender row with DEFAULT_SEND_BALANCE)
await requestSendAccess(request, jwt);
// Fetch address settings — balance should match DEFAULT_SEND_BALANCE=10
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.send_balance).toBe(10);
// Delete address
await deleteAddress(request, jwt);
// Verify address is gone — settings should fail
const afterDelete = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(afterDelete.ok()).toBe(false);
});
});

View File

@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Address Password Login', () => {
test('set password then login with it', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-login');
try {
// Set a password on the address
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'test-password-123' },
});
expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json();
expect(changePwdBody.success).toBe(true);
// Login with the correct password
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: 'test-password-123' },
});
expect(loginRes.ok()).toBe(true);
const loginBody = await loginRes.json();
expect(loginBody.jwt).toBeTruthy();
expect(loginBody.address).toBe(address);
// The new JWT should work — verify by fetching settings
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${loginBody.jwt}` },
});
expect(settingsRes.ok()).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('login with wrong password returns 401', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'pwd-wrong');
try {
// Set a password
const changePwdRes = await request.post(`${WORKER_URL}/api/address_change_password`, {
headers: { Authorization: `Bearer ${jwt}` },
data: { new_password: 'correct-password' },
});
expect(changePwdRes.ok()).toBe(true);
const changePwdBody = await changePwdRes.json();
expect(changePwdBody.success).toBe(true);
// Login with wrong password
const loginRes = await request.post(`${WORKER_URL}/api/address_login`, {
data: { email: address, password: 'wrong-password' },
});
expect(loginRes.status()).toBe(401);
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,80 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, seedTestMail, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Mail Deletion', () => {
test('delete a single mail by ID', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'del-single');
try {
// Seed 3 emails
for (let i = 1; i <= 3; i++) {
await seedTestMail(request, address, { subject: `Mail ${i}` });
}
// List mails — should have 3
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(listRes.ok()).toBe(true);
const { results } = await listRes.json();
expect(results).toHaveLength(3);
// Delete the second mail
const targetId = results[1].id;
const delRes = await request.delete(`${WORKER_URL}/api/mails/${targetId}`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(delRes.ok()).toBe(true);
const delBody = await delRes.json();
expect(delBody.success).toBe(true);
// List again — should have 2, and the deleted ID should be gone
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(afterRes.ok()).toBe(true);
const after = await afterRes.json();
expect(after.results).toHaveLength(2);
expect(after.results.every((m: any) => m.id !== targetId)).toBe(true);
} finally {
await deleteAddress(request, jwt);
}
});
test('clear entire inbox', async ({ request }) => {
const { jwt, address } = await createTestAddress(request, 'del-clear');
try {
// Seed 3 emails
for (let i = 1; i <= 3; i++) {
await seedTestMail(request, address, { subject: `Mail ${i}` });
}
// Verify 3 mails exist
const listRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(listRes.ok()).toBe(true);
const { results } = await listRes.json();
expect(results).toHaveLength(3);
// Clear inbox
const clearRes = await request.delete(`${WORKER_URL}/api/clear_inbox`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(clearRes.ok()).toBe(true);
const clearBody = await clearRes.json();
expect(clearBody.success).toBe(true);
// Verify inbox is empty
const afterRes = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(afterRes.ok()).toBe(true);
const after = await afterRes.json();
expect(after.results).toHaveLength(0);
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test';
import { WORKER_URL, createTestAddress, requestSendAccess, deleteAddress } from '../../fixtures/test-helpers';
test.describe('Send Access', () => {
test('request send access succeeds once, duplicate returns 400', async ({ request }) => {
const { jwt } = await createTestAddress(request, 'send-access');
try {
// First request — should succeed
await requestSendAccess(request, jwt);
// Verify balance is set via settings
const settingsRes = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(settingsRes.ok()).toBe(true);
const settings = await settingsRes.json();
expect(settings.send_balance).toBe(10);
// Duplicate request — should fail with 400
const dupRes = await request.post(`${WORKER_URL}/api/request_send_mail_access`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(dupRes.status()).toBe(400);
const dupBody = await dupRes.text();
expect(dupBody).toContain('Already');
} finally {
await deleteAddress(request, jwt);
}
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,3 @@
VITE_API_BASE=https://temp-email-api.xxx.xxx
VITE_CF_WEB_ANALY_TOKEN=
VITE_IS_TELEGRAM=false

2
frontend/.env.pages Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE=
VITE_CF_WEB_ANALY_TOKEN=

2
frontend/.gitignore vendored
View File

@@ -28,5 +28,7 @@ coverage
*.sw?
.env.*
!.env.example
!.env.pages
*-dist/
components.d.ts

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

View File

@@ -14,6 +14,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="icon" href="/logo.png" sizes="any">
<link rel="apple-touch-icon" href="/logo.png">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
</head>
<body>

View File

@@ -1,41 +1,60 @@
{
"name": "cloudflare_temp_email",
"version": "0.0.0",
"version": "1.5.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build -m prod --emptyOutDir",
"build:release": "vite build -m example --emptyOutDir",
"build:pages": "vite build -m pages --emptyOutDir",
"build:pages:nopwa": "VITE_PWA_DISABLED=true vite build -m pages --emptyOutDir",
"build:telegram": "VITE_IS_TELEGRAM=true vite build -m prod --emptyOutDir",
"build:telegram:pages": "VITE_IS_TELEGRAM=true vite build -m pages --emptyOutDir",
"build:telegram:release": "VITE_IS_TELEGRAM=true vite build -m example --emptyOutDir",
"preview": "vite preview",
"deploy:telegram": "npm run build:telegram && wrangler pages deploy ./dist --branch production",
"deploy:actions:telegram": "npm run build:telegram && wrangler pages deploy ./dist",
"deploy:preview": "npm run build && wrangler pages deploy ./dist --branch preview",
"deploy": "npm run build && wrangler pages deploy ./dist --branch production"
"deploy": "npm run build && wrangler pages deploy ./dist --branch production",
"deploy:actions": "npm run build && wrangler pages deploy ./dist",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.9.0",
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@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.6.8",
"mail-parser-wasm": "^0.1.6",
"naive-ui": "^2.38.2",
"postal-mime": "^2.2.5",
"axios": "^1.13.6",
"dompurify": "^3.3.3",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.3",
"vooks": "^0.2.12",
"vue": "^3.4.26",
"vue": "^3.5.30",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.2"
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vicons/fa": "^0.12.0",
"@vitejs/plugin-vue": "^5.0.4",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.27.0",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.19.8",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"workbox-window": "^7.1.0",
"wrangler": "^3.53.1"
}
"@vicons/fa": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vitejs/plugin-vue": "^6.0.4",
"jsdom": "^28.1.0",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"vitest": "^3.2.4",
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0",
"wrangler": "^4.72.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}

7010
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,43 @@
<script setup>
import { darkTheme, NGlobalStyle, zhCN } from 'naive-ui'
import { computed, onMounted } from 'vue'
import { useScript } from '@unhead/vue'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
import Header from './views/Header.vue';
import Footer from './views/Footer.vue';
import { api } from './api'
const { localeCache, isDark, loading } = useGlobalState()
const {
isDark, loading, useSideMargin, telegramApp, isTelegram
} = useGlobalState()
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
const { locale } = useI18n({});
const theme = computed(() => isDark.value ? darkTheme : null)
const localeConfig = computed(() => localeCache.value == 'zh' ? zhCN : null)
const localeConfig = computed(() => locale.value == 'zh' ? zhCN : null)
const isMobile = useIsMobile()
const showSideMargin = computed(() => !isMobile.value && useSideMargin.value);
const showAd = computed(() => !isMobile.value && adClient && adSlot);
const gridMaxCols = computed(() => showAd.value ? 8 : 12);
const { locale } = useI18n({
useScope: 'global',
});
locale.value = localeCache.value;
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) {
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
})
}
onMounted(async () => {
try {
await api.getUserSettings();
} catch (error) {
console.error(error);
}
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
@@ -30,6 +49,30 @@ onMounted(async () => {
document.body.appendChild(script);
}
// check if google ad is enabled
if (showAd.value) {
(window.adsbygoogle = window.adsbygoogle || []).push({});
(window.adsbygoogle = window.adsbygoogle || []).push({});
}
// check if telegram is enabled
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
if (
(typeof enableTelegram === 'boolean' && enableTelegram === true)
||
(typeof enableTelegram === 'string' && enableTelegram === 'true')
) {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-web-app.js';
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
telegramApp.value = window.Telegram?.WebApp || {};
isTelegram.value = !!window.Telegram?.WebApp?.initData;
}
});
</script>
@@ -37,24 +80,36 @@ onMounted(async () => {
<n-config-provider :locale="localeConfig" :theme="theme">
<n-global-style />
<n-spin description="loading..." :show="loading">
<n-message-provider>
<n-grid x-gap="12" :cols="12">
<n-gi v-if="!isMobile" span="1"></n-gi>
<n-gi :span="isMobile ? 12 : 10">
<div class="main">
<n-space vertical>
<n-layout style="min-height: 80vh;">
<Header />
<router-view></router-view>
</n-layout>
<Footer />
</n-space>
</div>
</n-gi>
<n-gi v-if="!isMobile" span="1"></n-gi>
</n-grid>
<n-back-top />
</n-message-provider>
<n-notification-provider container-style="margin-top: 60px;">
<n-message-provider container-style="margin-top: 20px;">
<n-grid x-gap="12" :cols="gridMaxCols">
<n-gi v-if="showSideMargin" span="1">
<div class="side" v-if="showAd">
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
data-ad-format="auto" data-full-width-responsive="true"></ins>
</div>
</n-gi>
<n-gi :span="!showSideMargin ? gridMaxCols : (gridMaxCols - 2)">
<div class="main">
<n-space vertical>
<n-layout style="min-height: 80vh;">
<Header />
<router-view></router-view>
</n-layout>
<Footer />
</n-space>
</div>
</n-gi>
<n-gi v-if="showSideMargin" span="1">
<div class="side" v-if="showAd">
<ins class="adsbygoogle" style="display:block" :data-ad-client="adClient" :data-ad-slot="adSlot"
data-ad-format="auto" data-full-width-responsive="true"></ins>
</div>
</n-gi>
</n-grid>
<n-back-top />
</n-message-provider>
</n-notification-provider>
</n-spin>
</n-config-provider>
</template>

View File

@@ -1,38 +1,51 @@
import { useGlobalState } from '../store'
import { h } from 'vue'
import axios from 'axios'
import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
const API_BASE = import.meta.env.VITE_API_BASE || "";
const { loading, auth, jwt, settings, openSettings } = useGlobalState();
const { showAuth, adminAuth, showAdminAuth } = useGlobalState();
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings, announcement,
showAuth, adminAuth, showAdminAuth, userJwt
} = useGlobalState();
const instance = axios.create({
baseURL: API_BASE,
timeout: 10000
timeout: 30000,
validateStatus: (status) => status >= 200 && status <= 500
});
const apiFetch = async (path, options = {}) => {
loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
const response = await instance.request(path, {
method: options.method || 'GET',
data: options.body || null,
headers: {
'x-lang': i18n.global.locale.value,
'x-user-token': options.userJwt || userJwt.value,
'x-user-access-token': userSettings.value.access_token,
'x-custom-auth': auth.value,
'x-admin-auth': adminAuth.value,
'x-fingerprint': fingerprint,
'Authorization': `Bearer ${jwt.value}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401 && openSettings.value.auth) {
showAuth.value = true;
throw new Error("Unauthorized, you password is wrong")
}
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
throw new Error("Unauthorized, you admin password is wrong")
}
if (response.status === 401 && openSettings.value.needAuth) {
showAuth.value = true;
}
if (response.status >= 300) {
throw new Error(`${response.status} ${response.data}` || "error");
throw new Error(`[${response.status}]: ${response.data}` || "error");
}
const data = response.data;
return data;
@@ -46,29 +59,63 @@ const apiFetch = async (path, options = {}) => {
}
}
const getOpenSettings = async (message) => {
const getOpenSettings = async (message, notification) => {
try {
const res = await api.fetch("/open_api/settings");
const domainLabels = res["domainLabels"] || [];
if (res["domains"]?.length < 1) {
message.error("No domains found, please check your worker settings");
}
Object.assign(openSettings.value, {
...res,
title: res["title"] || "",
prefix: res["prefix"] || "",
minAddressLen: res["minAddressLen"] || 1,
maxAddressLen: res["maxAddressLen"] || 30,
needAuth: res["needAuth"] || false,
domains: res["domains"].map((domain) => {
defaultDomains: res["defaultDomains"] || [],
domains: res["domains"].map((domain, index) => {
return {
label: domain,
label: domainLabels.length > index ? domainLabels[index] : domain,
value: domain
}
}),
adminContact: res["adminContact"] || "",
enableUserCreateEmail: res["enableUserCreateEmail"] || false,
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
disableCustomAddressName: res["disableCustomAddressName"] || false,
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
enableAutoReply: res["enableAutoReply"] || false,
enableIndexAbout: res["enableIndexAbout"] || false,
copyright: res["copyright"] || openSettings.value.copyright,
cfTurnstileSiteKey: res["cfTurnstileSiteKey"] || "",
enableWebhook: res["enableWebhook"] || false,
isS3Enabled: res["isS3Enabled"] || false,
enableAddressPassword: res["enableAddressPassword"] || false,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
});
if (openSettings.value.needAuth) {
showAuth.value = true;
}
if (openSettings.value.announcement
&& !openSettings.value.fetched
&& (openSettings.value.announcement != announcement.value
|| openSettings.value.alwaysShowAnnouncement)
) {
announcement.value = openSettings.value.announcement;
notification.info({
content: () => {
return h("div", {
innerHTML: announcement.value
});
}
});
}
} catch (error) {
message.error(error.message || "error");
} finally {
openSettings.value.fetched = true;
}
}
@@ -81,7 +128,6 @@ const getSettings = async () => {
settings.value = {
address: res["address"],
auto_reply: res["auto_reply"],
has_v1_mails: res["has_v1_mails"],
send_balance: res["send_balance"],
};
} finally {
@@ -89,10 +135,47 @@ const getSettings = async () => {
}
}
const adminShowPassword = async (id) => {
const getUserOpenSettings = async (message) => {
try {
const { password } = await apiFetch(`/admin/show_password/${id}`);
return password;
const res = await api.fetch(`/user_api/open_settings`);
Object.assign(userOpenSettings.value, res);
} catch (error) {
message.error(error.message || "fetch settings failed");
} finally {
userOpenSettings.value.fetched = true;
}
}
const getUserSettings = async (message) => {
try {
if (!userJwt.value) return;
const res = await api.fetch("/user_api/settings")
Object.assign(userSettings.value, res)
// auto refresh user jwt
if (userSettings.value.new_user_token) {
try {
await api.fetch("/user_api/settings", {
userJwt: userSettings.value.new_user_token,
})
userJwt.value = userSettings.value.new_user_token;
console.log("User JWT updated successfully");
}
catch (error) {
console.error("Failed to update user JWT", error);
}
}
} catch (error) {
message?.error(error.message || "error");
} finally {
userSettings.value.fetched = true;
}
}
const adminShowAddressCredential = async (id) => {
try {
const { jwt: addressCredential } = await apiFetch(`/admin/show_password/${id}`);
return addressCredential;
} catch (error) {
throw error;
}
@@ -108,10 +191,24 @@ const adminDeleteAddress = async (id) => {
}
}
const bindUserAddress = async () => {
if (!userJwt.value) return;
try {
await apiFetch(`/user_api/bind_address`, {
method: 'POST',
});
} catch (error) {
throw error;
}
}
export const api = {
fetch: apiFetch,
getSettings: getSettings,
getOpenSettings: getOpenSettings,
adminShowPassword: adminShowPassword,
adminDeleteAddress: adminDeleteAddress,
getSettings,
getOpenSettings,
getUserOpenSettings,
getUserSettings,
adminShowAddressCredential,
adminDeleteAddress,
bindUserAddress,
}

View File

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

View File

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

View File

@@ -1,63 +1,144 @@
<script setup>
import { watch, onMounted, ref, onBeforeUnmount } from "vue";
import { useRouter } from "vue-router";
import { watch, onMounted, ref, onBeforeUnmount, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { CloudDownloadRound, ReplyFilled } from '@vicons/material'
import { CloudDownloadRound, ArrowBackIosNewFilled, ArrowForwardIosFilled, InboxRound } from '@vicons/material'
import { useIsMobile } from '../utils/composables'
import { processItem, getDownloadEmlUrl } from '../utils/email-parser'
import { processItem } from '../utils/email-parser'
import { utcToLocalDate } from '../utils';
import { buildReplyModel, buildForwardModel } from '../utils/mail-actions'
import MailContentRenderer from "./MailContentRenderer.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
const message = useMessage()
const isMobile = useIsMobile()
const router = useRouter()
const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
requried: false
required: false
},
showEMailTo: {
type: Boolean,
default: true,
requried: false
required: false
},
fetchMailData: {
type: Function,
default: () => { },
requried: true
required: true
},
deleteMail: {
type: Function,
default: () => { },
requried: false
required: false
},
showReply: {
type: Boolean,
default: false,
requried: false
}
required: false
},
showSaveS3: {
type: Boolean,
default: false,
required: false
},
saveToS3: {
type: Function,
default: (mail_id, filename, blob) => { },
required: false
},
showFilterInput: {
type: Boolean,
default: false,
required: false
},
})
const localFilterKeyword = ref('')
const {
localeCache, isDark, mailboxSplitSize, useIframeShowMail, sendMailModel
isDark, mailboxSplitSize, indexTab, loading, useUTCDate,
autoRefresh, configAutoRefreshInterval, sendMailModel
} = useGlobalState()
const autoRefresh = ref(false)
const autoRefreshInterval = ref(30)
const data = ref([])
const autoRefreshInterval = ref(configAutoRefreshInterval.value)
const rawData = ref([])
const timer = ref(null)
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showAttachments = ref(false)
const curAttachments = ref([])
// Computed property for filtered data (only filter current page)
const data = computed(() => {
if (!localFilterKeyword.value || localFilterKeyword.value.trim() === '') {
return rawData.value;
}
const keyword = localFilterKeyword.value.toLowerCase();
return rawData.value.filter(mail => {
// Search in subject, text, message fields
const searchFields = [
mail.subject || '',
mail.text || '',
mail.message || ''
].map(field => field.toLowerCase());
return searchFields.some(field => field.includes(keyword));
});
})
const canGoPrevMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
return currentIndex > 0 || page.value > 1
})
const canGoNextMail = computed(() => {
if (!curMail.value) return false
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
return currentIndex < data.value.length - 1 || count.value > page.value * pageSize.value
})
const prevMail = async () => {
if (!canGoPrevMail.value) return
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex > 0) {
curMail.value = data.value[currentIndex - 1]
} else if (page.value > 1) {
page.value--
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[data.value.length - 1]
}
}
}
const nextMail = async () => {
if (!canGoNextMail.value) return
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
if (currentIndex < data.value.length - 1) {
curMail.value = data.value[currentIndex + 1]
} else if (count.value > page.value * pageSize.value) {
page.value++
await refresh()
if (data.value.length > 0) {
curMail.value = data.value[0]
}
}
}
const curMail = ref(null);
const multiActionMode = ref(false)
const showMultiActionDownload = ref(false)
const showMultiActionDelete = ref(false)
const multiActionDownloadZip = ref({})
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
success: 'Success',
@@ -66,10 +147,23 @@ const { t } = useI18n({
refresh: 'Refresh',
attachments: 'Show Attachments',
downloadMail: 'Download Mail',
pleaseSelectMail: "Please select a mail to view.",
pleaseSelectMail: "Please select mail",
emptyInbox: "Your inbox is empty",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete this mail?',
reply: 'Reply'
deleteMailTip: 'Are you sure you want to delete mail?',
reply: 'Reply',
forwardMail: 'Forward',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show Html Mail',
saveToS3: 'Save to S3',
multiAction: 'Multi Action',
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
prevMail: 'Previous',
nextMail: 'Next',
keywordQueryTip: 'Filter current page',
query: 'Query',
},
zh: {
success: '成功',
@@ -78,23 +172,38 @@ const { t } = useI18n({
refresh: '刷新',
downloadMail: '下载邮件',
attachments: '查看附件',
pleaseSelectMail: "请选择一封邮件查看。",
pleaseSelectMail: "请选择邮件",
emptyInbox: "收件箱为空",
delete: '删除',
deleteMailTip: '确定要删除这封邮件吗?',
reply: '回复'
deleteMailTip: '确定要删除邮件吗?',
reply: '回复',
forwardMail: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
prevMail: '上一封',
nextMail: '下一封',
keywordQueryTip: '过滤当前页',
query: '查询',
}
}
});
const setupAutoRefresh = async (autoRefresh) => {
// auto refresh every 30 seconds
autoRefreshInterval.value = 30;
// auto refresh every configAutoRefreshInterval seconds
autoRefreshInterval.value = configAutoRefreshInterval.value;
if (autoRefresh) {
clearInterval(timer.value);
timer.value = setInterval(async () => {
if (loading.value) return;
autoRefreshInterval.value--;
if (autoRefreshInterval.value <= 0) {
autoRefreshInterval.value = 30;
await refresh();
autoRefreshInterval.value = configAutoRefreshInterval.value;
await backFirstPageAndRefresh();
}
}, 1000)
} else {
@@ -105,7 +214,7 @@ const setupAutoRefresh = async (autoRefresh) => {
watch(autoRefresh, async (autoRefresh, old) => {
setupAutoRefresh(autoRefresh)
})
}, { immediate: true })
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
@@ -118,29 +227,39 @@ const refresh = async () => {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
data.value = await Promise.all(results.map(async (item) => {
loading.value = true;
rawData.value = await Promise.all(results.map(async (item) => {
item.checked = false;
return await processItem(item);
}));
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = null;
if (!isMobile.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
} finally {
loading.value = false;
}
};
const backFirstPageAndRefresh = async () => {
page.value = 1;
await refresh();
}
const clickRow = async (row) => {
if (multiActionMode.value) {
row.checked = !row.checked;
return;
}
curMail.value = row;
};
const getAttachments = (attachments) => {
curAttachments.value = attachments;
showAttachments.value = true;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
@@ -158,28 +277,99 @@ const deleteMail = async () => {
};
const replyMail = async () => {
const emailRegex = /(.+?) <(.+?)>/;
let toMail = curMail.value.originalSource;
let toName = ""
const match = emailRegex.exec(curMail.value.source);
if (match) {
toName = match[1];
toMail = match[2];
}
Object.assign(sendMailModel.value, {
toName: toName,
toMail: toMail,
subject: localeCache.value == 'zh' ? `回复: ${curMail.value.subject}` : `Re: ${curMail.value.subject}`,
contentType: 'text',
content: "",
});
await router.push('/send');
Object.assign(sendMailModel.value, buildReplyModel(curMail.value, t('reply')));
indexTab.value = 'sendmail';
};
const forwardMail = async () => {
Object.assign(sendMailModel.value, buildForwardModel(curMail.value, t('forwardMail')));
indexTab.value = 'sendmail';
};
const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
const saveToS3Proxy = async (filename, blob) => {
await props.saveToS3(curMail.value.id, filename, blob);
}
const multiActionModeClick = (enableMulti) => {
if (enableMulti) {
data.value.forEach((item) => {
item.checked = false;
});
multiActionMode.value = true;
} else {
multiActionMode.value = false;
data.value.forEach((item) => {
item.checked = false;
});
}
}
const multiActionSelectAll = (checked) => {
data.value.forEach((item) => {
item.checked = checked;
});
}
const multiActionDeleteMail = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
multiActionDeleteProgress.value = {
percentage: 0,
tip: `0/${selectedMails.length}`
};
for (const [index, mail] of selectedMails.entries()) {
await props.deleteMail(mail.id);
showMultiActionDelete.value = true;
multiActionDeleteProgress.value = {
percentage: Math.floor((index + 1) / selectedMails.length * 100),
tip: `${index + 1}/${selectedMails.length}`
};
}
message.success(t("success"));
await refresh();
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
showMultiActionDelete.value = true;
}
}
const multiActionDownload = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
const JSZipModlue = await import('jszip');
const JSZip = JSZipModlue.default;
const zip = new JSZip();
for (const mail of selectedMails) {
zip.file(`${mail.id}.eml`, mail.raw);
}
multiActionDownloadZip.value = {
url: URL.createObjectURL(await zip.generateAsync({ type: "blob" })),
filename: `mails-${new Date().toISOString().replace(/:/g, '-')}.zip`
}
showMultiActionDownload.value = true;
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
onMounted(async () => {
await refresh();
});
@@ -191,14 +381,38 @@ onBeforeUnmount(() => {
<template>
<div>
<n-split class="left" v-if="!isMobile" direction="horizontal" :max="0.75" :min="0.25"
:default-size="mailboxSplitSize" :on-update:size="onSpiltSizeChange">
<template #1>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<div v-if="!isMobile" class="left">
<div style="margin-bottom: 10px;">
<n-space v-if="multiActionMode" align="center">
<n-button @click="multiActionModeClick(false)" tertiary>
{{ t('cancelMultiAction') }}
</n-button>
<n-button @click="multiActionSelectAll(true)" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionSelectAll(false)" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
<template #trigger>
<n-button tertiary type="error">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button @click="multiActionDownload" tertiary type="info">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
</n-space>
<n-space v-else align="center">
<n-button @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" :page-sizes="[20, 50, 100]"
show-size-picker />
<n-switch v-model:value="autoRefresh" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
</template>
@@ -206,89 +420,89 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
<n-button @click="backFirstPageAndRefresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; height: 80vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
</n-tag>
<n-input v-if="showFilterInput" v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" style="width: 200px; display: flex; align-items: center;"
clearable />
</n-space>
</div>
<n-split class="left" direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card v-if="curMail" class="mail-item" :title="curMail.subject" style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail.raw)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
<n-ellipsis style="max-width: 240px;">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
</n-tag>
<n-tag v-if="showEMailTo" type="info">
<n-ellipsis style="max-width: 240px;">
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<div v-if="curMail" style="margin: 8px;">
<n-flex justify="space-between">
<n-button @click="prevMail" :disabled="!canGoPrevMail" text size="small">
<template #icon>
<n-icon>
<ArrowBackIosNewFilled />
</n-icon>
</template>
{{ t('prevMail') }}
</n-button>
<n-button @click="nextMail" :disabled="!canGoNextMail" text size="small" icon-placement="right">
<template #icon>
<n-icon>
<ArrowForwardIosFilled />
</n-icon>
</template>
{{ t('nextMail') }}
</n-button>
</n-flex>
</div>
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
style="overflow: auto; max-height: 100vh;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="count === 0 ? t('emptyInbox') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="CloudDownloadRound" />
<n-icon :component="InboxRound" :size="100" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
</n-space>
<iframe v-if="useIframeShowMail" :srcdoc="curMail.message"
style="margin-top: 10px;width: 100%; height: 100%;">
</iframe>
<div v-else v-html="curMail.message" style="margin-top: 10px;"></div>
</n-card>
<n-card class="mail-item" v-else>
<n-result status="info" :title="t('pleaseSelectMail')">
</n-result>
</n-card>
</template>
</n-split>
</n-result>
</n-card>
</template>
</n-split>
</div>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-top: 10px; margin-bottom: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-space justify="space-around" align="center" :wrap="false" style="display: flex; align-items: center;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
<n-switch v-model:value="autoRefresh" size="small" :round="false">
<template #checked>
{{ t('refreshAfter', { msg: autoRefreshInterval }) }}
@@ -297,11 +511,15 @@ onBeforeUnmount(() => {
{{ t('autoRefresh') }}
</template>
</n-switch>
<n-button @click="refresh" size="small" type="primary">
<n-button @click="backFirstPageAndRefresh" tertiary size="small" type="primary">
{{ t('refresh') }}
</n-button>
</n-space>
<div v-if="showFilterInput" style="padding: 0 10px; margin-top: 8px; margin-bottom: 10px;">
<n-input v-model:value="localFilterKeyword"
:placeholder="t('keywordQueryTip')" size="small" clearable />
</div>
<div style="overflow: auto; height: 80vh;">
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">
@@ -310,14 +528,19 @@ onBeforeUnmount(() => {
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ row.created_at }}
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ row.source }}
<n-ellipsis style="max-width: 240px;">
{{ showEMailTo ? "FROM: " + row.source : row.source }}
</n-ellipsis>
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ row.address }}
<n-ellipsis style="max-width: 240px;">
TO: {{ row.address }}
</n-ellipsis>
</n-tag>
<AiExtractInfo :metadata="row.metadata" compact />
</template>
</n-thing>
</n-list-item>
@@ -326,70 +549,34 @@ onBeforeUnmount(() => {
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ curMail.created_at }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ curMail.address }}
</n-tag>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="curMail.attachments && curMail.attachments.length > 0" size="small" tertiary type="info"
@click="getAttachments(curMail.attachments)">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="curMail.id + '.eml'"
:href="getDownloadEmlUrl(curMail)">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="replyMail">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
</n-space>
<div v-html="curMail.message" style="margin-top: 10px;"></div>
<n-card :bordered="false" embedded style="overflow: auto;">
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
:onSaveToS3="saveToS3Proxy" />
</n-card>
</n-drawer-content>
</n-drawer>
</div>
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("attachments") }}</div>
</template>
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
<n-modal v-model:show="showMultiActionDownload" preset="dialog" :title="t('downloadMail')">
<n-tag type="info">
{{ multiActionDownloadZip.filename }}
</n-tag>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="multiActionDownloadZip.filename"
:href="multiActionDownloadZip.url">
<n-icon :component="CloudDownloadRound" />
{{ t('downloadMail') + " zip" }}
</n-button>
</n-modal>
<n-modal v-model:show="showMultiActionDelete" preset="dialog" :title="t('delete') + t('success')"
negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="error" :percentage="multiActionDeleteProgress.percentage">
<span style="text-align: center">
{{ multiActionDeleteProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal>
</div>
</template>
@@ -420,4 +607,9 @@ onBeforeUnmount(() => {
.mail-item {
height: 100%;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,294 @@
<script setup>
import { ref } from "vue";
import { useI18n } from 'vue-i18n'
import { CloudDownloadRound, ReplyFilled, ForwardFilled, FullscreenRound } from '@vicons/material'
import ShadowHtmlComponent from "./ShadowHtmlComponent.vue";
import AiExtractInfo from "./AiExtractInfo.vue";
import { getDownloadEmlUrl } from '../utils/email-parser';
import { utcToLocalDate } from '../utils';
import { useGlobalState } from '../store';
const { preferShowTextMail, useIframeShowMail, useUTCDate, isDark } = useGlobalState();
const { t } = useI18n({
messages: {
en: {
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
attachments: 'View Attachments',
downloadMail: 'Download Mail',
reply: 'Reply',
forward: 'Forward',
showTextMail: 'Show Text Mail',
showHtmlMail: 'Show HTML Mail',
saveToS3: 'Save to S3',
size: 'Size',
fullscreen: 'Fullscreen',
},
zh: {
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
attachments: '查看附件',
downloadMail: '下载邮件',
reply: '回复',
forward: '转发',
showTextMail: '显示纯文本邮件',
showHtmlMail: '显示HTML邮件',
saveToS3: '保存到S3',
size: '大小',
fullscreen: '全屏',
}
}
});
const props = defineProps({
mail: {
type: Object,
required: true
},
showEMailTo: {
type: Boolean,
default: true
},
enableUserDeleteEmail: {
type: Boolean,
default: false
},
showReply: {
type: Boolean,
default: false
},
showSaveS3: {
type: Boolean,
default: false
},
// 回调函数 props
onDelete: {
type: Function,
default: () => { }
},
onReply: {
type: Function,
default: () => { }
},
onForward: {
type: Function,
default: () => { }
},
onSaveToS3: {
type: Function,
default: () => { }
}
});
const showTextMail = ref(preferShowTextMail.value);
const showAttachments = ref(false);
const curAttachments = ref([]);
const attachmentLoding = ref(false);
const showFullscreen = ref(false);
const handleDelete = () => {
props.onDelete();
};
const handleViewAttachments = () => {
curAttachments.value = props.mail.attachments;
showAttachments.value = true;
};
const handleReply = () => {
props.onReply();
};
const handleForward = () => {
props.onForward();
};
const handleSaveToS3 = async (filename, blob) => {
attachmentLoding.value = true;
try {
await props.onSaveToS3(filename, blob);
} finally {
attachmentLoding.value = false;
}
};
</script>
<template>
<div class="mail-content-renderer">
<!-- 邮件信息标签 -->
<n-space>
<n-tag type="info">
ID: {{ mail.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(mail.created_at, useUTCDate.value) }}
</n-tag>
<n-tag type="info">
FROM: {{ mail.source }}
</n-tag>
<n-tag v-if="showEMailTo" type="info">
TO: {{ mail.address }}
</n-tag>
<!-- 操作按钮 -->
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="handleDelete">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
<n-button v-if="mail.attachments && mail.attachments.length > 0" size="small" tertiary type="info"
@click="handleViewAttachments">
{{ t('attachments') }}
</n-button>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
:href="getDownloadEmlUrl(mail.raw)">
<template #icon>
<n-icon :component="CloudDownloadRound" />
</template>
{{ t('downloadMail') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleReply">
<template #icon>
<n-icon :component="ReplyFilled" />
</template>
{{ t('reply') }}
</n-button>
<n-button v-if="showReply" size="small" tertiary type="info" @click="handleForward">
<template #icon>
<n-icon :component="ForwardFilled" />
</template>
{{ t('forward') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showTextMail = !showTextMail">
{{ showTextMail ? t('showHtmlMail') : t('showTextMail') }}
</n-button>
<n-button size="small" tertiary type="info" @click="showFullscreen = true">
<template #icon>
<n-icon :component="FullscreenRound" />
</template>
{{ t('fullscreen') }}
</n-button>
</n-space>
<!-- AI 提取信息 -->
<AiExtractInfo :metadata="mail.metadata" />
<!-- 邮件内容 -->
<div class="mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
</div>
</div>
<n-drawer v-model:show="showFullscreen" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 100vh;">
<n-drawer-content :title="mail.subject" closable>
<div class="fullscreen-mail-content" :class="{ 'dark-mode': isDark }">
<pre v-if="showTextMail" class="mail-text">{{ mail.text }}</pre>
<iframe v-else-if="useIframeShowMail" :srcdoc="mail.message" class="mail-iframe">
</iframe>
<ShadowHtmlComponent v-else :key="mail.id" :htmlContent="mail.message" :isDark="isDark" class="mail-html" />
</div>
</n-drawer-content>
</n-drawer>
<!-- 附件模态框 -->
<n-modal v-model:show="showAttachments" preset="dialog" title="Dialog">
<template #header>
<div>{{ t('attachments') }}</div>
</template>
<n-spin v-model:show="attachmentLoding">
<n-list hoverable clickable>
<n-list-item v-for="row in curAttachments" v-bind:key="row.id">
<n-thing class="center" :title="row.filename">
<template #description>
<n-space>
<n-tag type="info">
Size: {{ row.size }}
</n-tag>
<n-button v-if="showSaveS3" @click="handleSaveToS3(row.filename, row.blob)" ghost type="info"
size="small">
{{ t('saveToS3') }}
</n-button>
</n-space>
</template>
</n-thing>
<template #suffix>
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="row.filename"
:href="row.url">
<n-icon :component="CloudDownloadRound" />
</n-button>
</template>
</n-list-item>
</n-list>
</n-spin>
</n-modal>
</template>
<style scoped>
.mail-content-renderer {
display: flex;
flex-direction: column;
gap: 10px;
}
.mail-content {
margin-top: 10px;
flex: 1;
}
.mail-text {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
padding: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.dark-mode .mail-text {
color: #e0e0e0;
}
.mail-iframe {
width: 100%;
height: 100%;
border: none;
min-height: 400px;
}
.dark-mode .mail-iframe {
background-color: #fff;
}
.mail-html {
width: 100%;
height: 100%;
}
.center {
text-align: center;
}
.fullscreen-mail-content {
height: calc(100vh - 120px);
overflow: auto;
}
.fullscreen-mail-content .mail-iframe {
min-height: calc(100vh - 120px);
}
</style>

View File

@@ -0,0 +1,411 @@
<script setup>
import { watch, onMounted, ref, computed } from "vue";
import { useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
import { useIsMobile } from '../utils/composables'
import { utcToLocalDate } from '../utils';
import { SendRound } from '@vicons/material'
const message = useMessage()
const isMobile = useIsMobile()
const props = defineProps({
enableUserDeleteEmail: {
type: Boolean,
default: false,
required: false
},
showEMailFrom: {
type: Boolean,
default: false
},
fetchMailData: {
type: Function,
default: () => { },
required: true
},
deleteMail: {
type: Function,
default: () => { },
required: false
},
})
const { isDark, mailboxSplitSize, loading, useUTCDate } = useGlobalState()
const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const curMail = ref(null);
const showCode = ref(false)
const multiActionMode = ref(false)
const showMultiActionDelete = ref(false)
const multiActionDeleteProgress = ref({ percentage: 0, tip: '0/0' })
const { t } = useI18n({
messages: {
en: {
success: 'Success',
refresh: 'Refresh',
showCode: 'Change View Original Code',
pleaseSelectMail: "Please select a mail to view.",
emptySent: "No sent emails",
delete: 'Delete',
deleteMailTip: 'Are you sure you want to delete mail?',
multiAction: 'Multi Action',
cancelMultiAction: 'Cancel Multi Action',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
},
zh: {
success: '成功',
refresh: '刷新',
showCode: '切换查看元数据',
pleaseSelectMail: "请选择一封邮件查看。",
emptySent: "发件箱为空",
delete: '删除',
deleteMailTip: '确定要删除邮件吗?',
multiAction: '多选',
cancelMultiAction: '取消多选',
selectAll: '全选本页',
unselectAll: '取消全选',
}
}
});
watch([page, pageSize], async ([page, pageSize], [oldPage, oldPageSize]) => {
if (page !== oldPage || pageSize !== oldPageSize) {
await refresh();
}
})
const refresh = async () => {
try {
const { results, count: totalCount } = await props.fetchMailData(
pageSize.value, (page.value - 1) * pageSize.value
);
data.value = results.map((item) => {
try {
const data = JSON.parse(item.raw);
if (data.version == "v2") {
item.to_mail = data.to_name ? `${data.to_name} <${data.to_mail}>` : data.to_mail;
item.subject = data.subject;
item.is_html = data.is_html;
item.content = data.content;
item.raw = JSON.stringify(data, null, 2);
} else {
item.to_mail = data?.personalizations?.map(
(p) => p.to?.map((t) => t.email).join(',')
).join(';');
item.subject = data.subject;
item.is_html = (data.content[0]?.type != 'text/plain');
item.content = data.content[0]?.value;
item.raw = JSON.stringify(data, null, 2);
}
} catch (error) {
console.log(error);
}
return item;
});
if (totalCount > 0) {
count.value = totalCount;
}
if (!isMobile.value && !curMail.value && data.value.length > 0) {
curMail.value = data.value[0];
}
} catch (error) {
message.error(error.message || "error");
console.error(error);
}
};
const clickRow = async (row) => {
curMail.value = row;
};
const mailItemClass = (row) => {
return curMail.value && row.id == curMail.value.id ? (isDark.value ? 'overlay overlay-dark-backgroud' : 'overlay overlay-light-backgroud') : '';
};
const onSpiltSizeChange = (size) => {
mailboxSplitSize.value = size;
}
const deleteMail = async () => {
try {
await props.deleteMail(curMail.value.id);
message.success(t("success"));
curMail.value = null;
await refresh();
} catch (error) {
message.error(error.message || "error");
}
};
const showMultiActionMode = computed(() => {
return props.enableUserDeleteEmail;
});
const multiActionModeClick = (enableMulti) => {
if (enableMulti) {
data.value.forEach((item) => {
item.checked = false;
});
multiActionMode.value = true;
} else {
multiActionMode.value = false;
data.value.forEach((item) => {
item.checked = false;
});
}
}
const multiActionSelectAll = (checked) => {
data.value.forEach((item) => {
item.checked = checked;
});
}
const multiActionDeleteMail = async () => {
try {
loading.value = true;
const selectedMails = data.value.filter((item) => item.checked);
if (selectedMails.length === 0) {
message.error(t('pleaseSelectMail'));
return;
}
multiActionDeleteProgress.value = {
percentage: 0,
tip: `0/${selectedMails.length}`
};
for (const [index, mail] of selectedMails.entries()) {
await props.deleteMail(mail.id);
showMultiActionDelete.value = true;
multiActionDeleteProgress.value = {
percentage: Math.floor((index + 1) / selectedMails.length * 100),
tip: `${index + 1}/${selectedMails.length}`
};
}
message.success(t("success"));
await refresh();
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
showMultiActionDelete.value = true;
}
}
onMounted(async () => {
await refresh();
});
</script>
<template>
<div>
<div v-if="!isMobile" class="left">
<div style="margin-bottom: 10px;">
<n-space v-if="multiActionMode">
<n-button @click="multiActionModeClick(false)" tertiary>
{{ t('cancelMultiAction') }}
</n-button>
<n-button @click="multiActionSelectAll(true)" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionSelectAll(false)" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="multiActionDeleteMail">
<template #trigger>
<n-button tertiary type="error">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
</n-space>
<n-space v-else>
<n-button v-if="showMultiActionMode" @click="multiActionModeClick(true)" type="primary" tertiary>
{{ t('multiAction') }}
</n-button>
<div style="display: inline-block; margin-right: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker />
</div>
<n-button @click="refresh" type="primary" tertiary>
{{ t('refresh') }}
</n-button>
</n-space>
</div>
<n-split direction="horizontal" :max="0.75" :min="0.25" :default-size="mailboxSplitSize"
:on-update:size="onSpiltSizeChange">
<template #1>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
:class="mailItemClass(row)">
<template #prefix v-if="multiActionMode">
<n-checkbox v-model:checked="row.checked" />
</template>
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
</n-tag>
<n-tag type="info">
TO: {{ row.to_mail }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
</template>
<template #2>
<n-card :bordered="false" embedded v-if="curMail" class="mail-item" :title="curMail.subject"
style="overflow: auto; max-height: 100vh;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
</n-tag>
<n-tag type="info">
TO: {{ curMail.to_mail }}
</n-tag>
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
{{ t('showCode') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
</n-space>
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
<n-card :bordered="false" embedded class="mail-item" v-else>
<n-result status="info" :title="count === 0 ? t('emptySent') : t('pleaseSelectMail')">
<template #icon>
<n-icon :component="SendRound" :size="100" />
</template>
</n-result>
</n-card>
</template>
</n-split>
</div>
<div class="left" v-else>
<div class="center">
<div style="display: inline-block; margin-right: 10px;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count" simple size="small" />
</div>
<n-button @click="refresh" size="small" type="primary">
{{ t('refresh') }}
</n-button>
</div>
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
<n-list hoverable clickable>
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
<n-thing :title="row.subject">
<template #description>
<n-tag type="info">
ID: {{ row.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(row.created_at, useUTCDate) }}
</n-tag>
<n-tag v-if="showEMailFrom" type="info">
FROM: {{ row.address }}
</n-tag>
<n-tag type="info">
TO: {{ row.to_mail }}
</n-tag>
</template>
</n-thing>
</n-list-item>
</n-list>
</div>
<n-drawer v-model:show="curMail" width="100%" placement="bottom" :trap-focus="false" :block-scroll="false"
style="height: 80vh;">
<n-drawer-content :title="curMail ? curMail.subject : ''" closable>
<n-card :bordered="false" embedded style="overflow: auto;">
<n-space>
<n-tag type="info">
ID: {{ curMail.id }}
</n-tag>
<n-tag type="info">
{{ utcToLocalDate(curMail.created_at, useUTCDate) }}
</n-tag>
<n-tag type="info">
FROM: {{ curMail.address }}
</n-tag>
<n-tag type="info">
TO: {{ curMail.to_mail }}
</n-tag>
<n-button size="small" tertiary type="info" @click="showCode = !showCode">
{{ t('showCode') }}
</n-button>
<n-popconfirm v-if="enableUserDeleteEmail" @positive-click="deleteMail">
<template #trigger>
<n-button tertiary type="error" size="small">{{ t('delete') }}</n-button>
</template>
{{ t('deleteMailTip') }}
</n-popconfirm>
</n-space>
<pre v-if="showCode" style="margin-top: 10px;">{{ curMail.raw }}</pre>
<pre v-else-if="!curMail.is_html" style="margin-top: 10px;">{{ curMail.content }}</pre>
<div v-else v-html="curMail.content" style="margin-top: 10px;"></div>
</n-card>
</n-drawer-content>
</n-drawer>
</div>
</div>
</template>
<style scoped>
.left {
text-align: left;
}
.center {
text-align: center;
}
.overlay {
width: 100%;
height: 100%;
z-index: 1000;
}
.overlay-dark-backgroud {
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-light-backgroud {
background-color: rgba(0, 0, 0, 0.1);
}
.mail-item {
height: 100%;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div v-if="useFallback" v-html="htmlContent"></div>
<div v-else ref="shadowHost"></div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
htmlContent: {
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
});
const shadowHost = ref(null);
let shadowRoot = null;
const useFallback = ref(false);
/**
* Renders content into Shadow DOM with fallback to v-html
*/
const renderShadowDom = () => {
if (!shadowHost.value && !useFallback.value) return;
try {
// Don't attempt to use Shadow DOM if already in fallback mode
if (useFallback.value) return;
// Initialize Shadow DOM if not already created
if (!shadowRoot && shadowHost.value) {
try {
shadowRoot = shadowHost.value.attachShadow({ mode: 'open' });
} catch (error) {
console.warn('Shadow DOM not supported, falling back to v-html:', error);
useFallback.value = true;
return;
}
}
// Update content if Shadow DOM exists
if (shadowRoot) {
const darkModeStyle = props.isDark
? `<style>
:host { color: #e0e0e0; }
a { color: #A8C7FA; }
</style>`
: '';
shadowRoot.innerHTML = darkModeStyle + props.htmlContent;
}
} catch (error) {
console.error('Failed to render Shadow DOM, falling back to v-html:', error);
useFallback.value = true;
}
};
// Initial render when component is mounted
onMounted(() => {
// Check if Shadow DOM is supported in this browser
if (typeof Element.prototype.attachShadow !== 'function') {
console.warn('Shadow DOM is not supported in this browser, using v-html fallback');
useFallback.value = true;
return;
}
renderShadowDom();
});
// Clean up resources when component is unmounted
onBeforeUnmount(() => {
if (shadowRoot) {
shadowRoot.innerHTML = '';
}
shadowRoot = null;
});
// Update Shadow DOM when htmlContent or dark mode changes
watch(() => [props.htmlContent, props.isDark], () => {
renderShadowDom();
}, { flush: 'post' });
</script>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, watch, onMounted } from "vue";
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { openSettings, isDark } = useGlobalState()
const cfToken = defineModel('value')
const { locale, t } = useI18n({
messages: {
en: {
refresh: 'Refresh'
},
zh: {
refresh: '刷新'
}
}
});
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(containerId);
let count = 100;
while (!container && count-- > 0) {
container = document.getElementById(containerId);
await new Promise(r => setTimeout(r, 10));
}
count = 100;
while (!window.turnstile && count-- > 0) {
await new Promise(r => setTimeout(r, 10));
}
if (remove && cfTurnstileId.value) {
window.turnstile.remove(cfTurnstileId.value);
}
cfTurnstileId.value = window.turnstile.render(
`#${containerId}`,
{
sitekey: openSettings.value.cfTurnstileSiteKey,
language: locale.value == 'zh' ? 'zh-CN' : 'en-US',
theme: isDark.value ? 'dark' : 'light',
callback: function (token) {
cfToken.value = token;
},
}
);
} finally {
turnstileLoading.value = false;
}
}
watch(isDark, async (isDark) => {
checkCfTurnstile(true)
}, { immediate: true })
onMounted(() => {
cfToken.value = "";
checkCfTurnstile(true);
})
</script>
<template>
<div v-if="openSettings.cfTurnstileSiteKey" class="center">
<n-spin description="loading..." :show="turnstileLoading">
<n-form-item-row>
<n-flex vertical>
<div :id="containerId"></div>
<n-button text @click="checkCfTurnstile(true)">
{{ t('refresh') }}
</n-button>
</n-flex>
</n-form-item-row>
</n-spin>
</div>
</template>
<style scoped>
.center {
display: flex;
}
.n-button {
margin-left: 10px;
}
</style>

View File

@@ -0,0 +1,282 @@
<script setup lang="ts">
import { onMounted, ref, h } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DropdownOption } from 'naive-ui'
const props = defineProps({
fetchData: {
type: Function,
default: () => { },
required: true
},
saveSettings: {
type: Function,
default: (webhookSettings: WebhookSettings) => { },
required: true
},
testSettings: {
type: Function,
default: (webhookSettings: WebhookSettings) => { },
required: true
},
})
// @ts-ignore
const message = useMessage()
const { t } = useI18n({
messages: {
en: {
successTip: 'Success',
test: 'Test',
save: 'Save',
notEnabled: 'Webhook is not enabled for you',
urlMissing: 'URL is required',
enable: 'Enable',
presets: 'Presets',
fillInDemoTip: 'Please modify the URL and other settings to your own',
},
zh: {
successTip: '成功',
test: '测试',
save: '保存',
notEnabled: 'Webhook 未开启,请联系管理员开启',
urlMissing: 'URL 不能为空',
enable: '启用',
presets: '示例模板',
fillInDemoTip: '请修改URL和其他设置为您自己的配置',
}
}
});
class WebhookSettings {
enabled: boolean = false
url: string = ''
method: string = 'POST'
headers: string = JSON.stringify({}, null, 2)
body: string = JSON.stringify({}, null, 2)
}
interface WebhookPreset {
name: string
doc: string
settings: WebhookSettings
}
const presets: WebhookPreset[] = [
{
name: 'Message Pusher',
doc: 'https://github.com/songquanpeng/message-pusher',
settings: {
enabled: true,
url: 'https://msgpusher.com/push/username',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"token": "token",
"title": "${subject}",
"description": "${subject}",
"content": "*${subject}*\n\nFrom: ${from}\nTo: ${to}\n\n${parsedText}\n"
}, null, 2),
},
},
{
name: 'Bark',
doc: 'https://github.com/Finb/Bark',
settings: {
enabled: true,
url: 'https://api.day.app/YOUR_KEY',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"title": "${subject}",
"body": "From: ${from}\nTo: ${to}\n\n${parsedText}",
"group": "email"
}, null, 2),
},
},
{
name: 'ntfy',
doc: 'https://docs.ntfy.sh/publish/',
settings: {
enabled: true,
url: 'https://ntfy.sh/YOUR_TOPIC',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"topic": "YOUR_TOPIC",
"title": "${subject}",
"message": "From: ${from}\nTo: ${to}\n\n${parsedText}",
"tags": ["envelope"]
}, null, 2),
},
},
{
name: 'Telegram Bot',
doc: 'https://core.telegram.org/bots/api#sendmessage',
settings: {
enabled: true,
url: 'https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"chat_id": "YOUR_CHAT_ID",
"text": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}, null, 2),
},
},
{
name: 'WeChat Work',
doc: 'https://developer.work.weixin.qq.com/document/path/91770',
settings: {
enabled: true,
url: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"msgtype": "text",
"text": {
"content": "New Email\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}
}, null, 2),
},
},
{
name: 'Discord',
doc: 'https://discord.com/developers/docs/resources/webhook',
settings: {
enabled: true,
url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN',
method: 'POST',
headers: JSON.stringify({
'Content-Type': 'application/json',
}, null, 2),
body: JSON.stringify({
"content": "**New Email**\nFrom: ${from}\nTo: ${to}\nSubject: ${subject}\nURL: ${url}"
}, null, 2),
},
},
]
const presetDropdownOptions: DropdownOption[] = presets.map((preset, index) => ({
label: preset.name,
key: index,
}))
const handlePresetSelect = (key: number) => {
const preset = presets[key]
if (!preset) {
message.error('Invalid preset')
return
}
Object.assign(webhookSettings.value, preset.settings)
message.success(t('fillInDemoTip'))
window.open(preset.doc, '_blank', 'noopener,noreferrer')
}
const webhookSettings = ref<WebhookSettings>(new WebhookSettings())
const enableWebhook = ref(false)
const fetchData = async () => {
try {
const res = await props.fetchData()
Object.assign(webhookSettings.value, res)
enableWebhook.value = true
} catch (error) {
message.error((error as Error).message || "error");
}
}
const saveSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await props.saveSettings(webhookSettings.value)
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
const testSettings = async () => {
if (!webhookSettings.value.url) {
message.error(t('urlMissing'))
return
}
try {
await props.testSettings(webhookSettings.value)
message.success(t('successTip'))
} catch (error) {
message.error((error as Error).message || "error");
}
}
onMounted(async () => {
await fetchData();
})
</script>
<template>
<div class="center">
<n-card :bordered="false" embedded v-if="enableWebhook" style="max-width: 800px; overflow: auto;">
<n-flex justify="end">
<n-dropdown :options="presetDropdownOptions" @select="handlePresetSelect">
<n-button secondary>
{{ t('presets') }}
</n-button>
</n-dropdown>
<n-button v-if="webhookSettings.enabled" @click="testSettings" secondary>
{{ t('test') }}
</n-button>
<n-button @click="saveSettings" type="primary">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('enable')">
<n-switch v-model:value="webhookSettings.enabled" :round="false" />
</n-form-item-row>
<div v-if="webhookSettings.enabled">
<n-form-item-row label="URL">
<n-input v-model:value="webhookSettings.url" />
</n-form-item-row>
<n-form-item-row label="METHOD">
<n-select v-model:value="webhookSettings.method" tag :options='[
{ label: "POST", value: "POST" }
]' />
</n-form-item-row>
<n-form-item-row label="HEADERS">
<n-input v-model:value="webhookSettings.headers" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-form-item-row label="BODY">
<n-input v-model:value="webhookSettings.body" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
</div>
</n-card>
<n-result v-else status="404" :title="t('notEnabled')" />
</div>
</template>
<style scoped>
.center {
display: flex;
text-align: left;
place-items: center;
justify-content: center;
}
.n-button {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,8 @@
const COMMOM_MAIL = [
"gmail.com", "163.com", "126.com", "qq.com", "outlook.com", "hotmail.com",
"icloud.com", "yahoo.com", "foxmail.com"
]
export default {
COMMOM_MAIL
}

15
frontend/src/i18n.ts Normal file
View File

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

View File

@@ -1,22 +1,13 @@
import { createApp } from 'vue'
import App from './App.vue'
import { createI18n } from 'vue-i18n'
import router from './router'
import { registerSW } from 'virtual:pwa-register'
import { createHead } from '@unhead/vue/client'
registerSW({ immediate: true })
const i18n = createI18n({
legacy: false, // you must set `false`, to use Composition API
locale: 'zh', // set locale
fallbackLocale: 'en', // set fallback locale
'en': {
messages: {}
},
'zh': {
messages: {}
}
})
import App from './App.vue'
import router from './router'
import i18n from './i18n'
const head = createHead()
const app = createApp(App)
app.use(i18n)
app.use(router)
app.use(head)
app.mount('#app')

View File

@@ -0,0 +1,19 @@
export type UserOauth2Settings = {
name: string;
icon?: string; // SVG icon string for the provider
clientID: string;
clientSecret: string;
authorizationURL: string;
accessTokenURL: string;
accessTokenFormat?: string;
userInfoURL: string;
redirectURL: string;
logoutURL?: string;
userEmailKey: string;
enableEmailFormat?: boolean; // Enable email format transformation
userEmailFormat?: string; // Regex pattern to match email
userEmailReplace?: string; // Replacement template using $1, $2, etc.
scope: string;
enableMailAllowList?: boolean | undefined;
mailAllowList?: string[] | undefined;
}

View File

@@ -1,29 +1,60 @@
import { createRouter, createWebHistory } from 'vue-router'
import Index from '../views/Index.vue'
import User from '../views/User.vue'
import SendMail from '../views/send/SendMail.vue'
import Admin from '../views/Admin.vue'
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
import i18n from '../i18n'
import { useGlobalState } from '../store'
const { jwt } = useGlobalState()
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
alias: "/:lang/",
component: Index
},
{
path: '/user',
alias: "/:lang/user",
component: User
},
{
path: '/send',
component: SendMail
path: '/user/oauth2/callback',
alias: "/:lang/user/oauth2/callback",
component: UserOauth2Callback
},
{
path: '/admin',
component: Admin
alias: "/:lang/admin",
component: () => import('../views/Admin.vue')
},
{
path: '/telegram_mail',
alias: "/:lang/telegram_mail",
component: () => import('../views/telegram/Mail.vue')
},
{
name: 'not-found',
path: '/:pathMatch(.*)*',
redirect: '/'
}
]
})
});
router.beforeEach((to, from, next) => {
if (to.params.lang && ['en', 'zh'].includes(to.params.lang)) {
i18n.global.locale.value = to.params.lang
} else {
i18n.global.locale.value = 'zh'
}
// check if query parameter has jwt, set it to store
if (to.query.jwt) {
jwt.value = to.query.jwt;
}
next()
});
export default router

View File

@@ -1,25 +1,48 @@
import { ref } from "vue";
import { createGlobalState, useStorage } from '@vueuse/core'
import { useDark, useToggle } from '@vueuse/core'
import { computed, ref } from "vue";
import {
createGlobalState, useStorage, useDark, useToggle,
useLocalStorage, useSessionStorage
} from '@vueuse/core'
export const useGlobalState = createGlobalState(
() => {
const isDark = useDark()
const toggleDark = useToggle(isDark)
const loading = ref(false);
const announcement = useLocalStorage('announcement', '');
const useSimpleIndex = useLocalStorage('useSimpleIndex', false);
const openSettings = ref({
fetched: false,
title: '',
announcement: '',
alwaysShowAnnouncement: false,
prefix: '',
addressRegex: '',
needAuth: false,
adminContact: '',
enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableAutoReply: false,
enableIndexAbout: false,
/** @type {string[]} */
defaultDomains: [],
/** @type {Array<{label: string, value: string}>} */
domains: [],
copyright: 'Dream Hunter',
cfTurnstileSiteKey: '',
enableWebhook: false,
isS3Enabled: false,
enableSendMail: false,
showGithub: true,
disableAdminPasswordCheck: false,
enableAddressPassword: false,
statusUrl: '',
enableGlobalTurnstileCheck: false,
})
const settings = ref({
fetched: false,
has_v1_mails: false,
send_balance: 0,
address: '',
auto_reply: {
@@ -30,7 +53,7 @@ export const useGlobalState = createGlobalState(
name: '',
}
});
const sendMailModel = useStorage('sendMailModel', {
const sendMailModel = useSessionStorage('sendMailModel', {
fromName: "",
toName: "",
toMail: "",
@@ -39,29 +62,81 @@ export const useGlobalState = createGlobalState(
content: "",
});
const showAuth = ref(false);
const showPassword = ref(false);
const showAddressCredential = ref(false);
const showAdminAuth = ref(false);
const auth = useStorage('auth', '');
const adminAuth = useStorage('adminAuth', '');
const jwt = useStorage('jwt', '');
const localeCache = useStorage('locale', 'zh');
const adminTab = ref("account");
const addressPassword = useSessionStorage('addressPassword', '');
const adminTab = useSessionStorage('adminTab', "account");
const adminMailTabAddress = ref("");
const adminSendBoxTabAddress = ref("");
const mailboxSplitSize = useStorage('mailboxSplitSize', 0.25);
const useIframeShowMail = useStorage('useIframeShowMail', false);
const preferShowTextMail = useStorage('preferShowTextMail', false);
const userJwt = useStorage('userJwt', '');
const userTab = useSessionStorage('userTab', 'address_management');
const indexTab = useSessionStorage('indexTab', 'mailbox');
const globalTabplacement = useStorage('globalTabplacement', 'top');
const useSideMargin = useStorage('useSideMargin', true);
const useUTCDate = useStorage('useUTCDate', false);
const autoRefresh = useStorage('autoRefresh', false);
const configAutoRefreshInterval = useStorage("configAutoRefreshInterval", 60);
const userOpenSettings = ref({
fetched: false,
enable: false,
enableMailVerify: false,
/** @type {{ clientID: string, name: string, icon?: string }[]} */
oauth2ClientIDs: [],
});
const userSettings = ref({
/** @type {boolean} */
fetched: false,
/** @type {string} */
user_email: '',
/** @type {number} */
user_id: 0,
/** @type {boolean} */
is_admin: false,
/** @type {string | null} */
access_token: null,
/** @type {string | null} */
new_user_token: null,
/** @type {null | {domains: string[] | undefined | null, role: string, prefix: string | undefined | null}} */
user_role: null,
});
const showAdminPage = computed(() =>
!!adminAuth.value
|| userSettings.value.is_admin
|| openSettings.value.disableAdminPasswordCheck
);
const telegramApp = ref(window.Telegram?.WebApp || {});
const isTelegram = ref(!!window.Telegram?.WebApp?.initData);
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,
toggleDark,
loading,
settings,
sendMailModel,
announcement,
openSettings,
showAuth,
showPassword,
showAddressCredential,
auth,
jwt,
localeCache,
adminAuth,
showAdminAuth,
adminTab,
@@ -69,6 +144,25 @@ export const useGlobalState = createGlobalState(
adminSendBoxTabAddress,
mailboxSplitSize,
useIframeShowMail,
preferShowTextMail,
userJwt,
userTab,
indexTab,
userOpenSettings,
userSettings,
globalTabplacement,
useSideMargin,
useUTCDate,
autoRefresh,
configAutoRefreshInterval,
telegramApp,
isTelegram,
showAdminPage,
userOauth2SessionState,
userOauth2SessionClientID,
useSimpleIndex,
addressPassword,
browserFingerprint,
}
},
)

View File

@@ -0,0 +1,265 @@
// @vitest-environment jsdom
import { describe, it, expect } from 'vitest'
import { buildReplyModel, buildForwardModel } from '../mail-actions'
describe('buildReplyModel', () => {
it('uses HTML content in blockquote when message is present', () => {
const mail = {
source: 'Alice <alice@example.com>',
originalSource: 'alice@example.com',
subject: 'Hello',
message: '<p>HTML body</p>',
text: 'Plain body',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).toBe(
'<p><br></p><blockquote><p>HTML body</p></blockquote><p><br></p>'
)
expect(result.contentType).toBe('html')
})
it('falls back to plain text when message is empty string', () => {
const mail = {
source: 'bob@example.com',
originalSource: 'bob@example.com',
subject: 'Hi',
message: '',
text: 'Plain text fallback',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).toBe(
'<p><br></p><blockquote>Plain text fallback</blockquote><p><br></p>'
)
expect(result.contentType).toBe('rich')
})
it('falls back to plain text when message is null', () => {
const mail = {
source: 'carol@example.com',
originalSource: 'carol@example.com',
subject: 'Test',
message: null,
text: 'Fallback text',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).toBe(
'<p><br></p><blockquote>Fallback text</blockquote><p><br></p>'
)
})
it('returns empty content when both message and text are empty', () => {
const mail = {
source: 'dave@example.com',
originalSource: 'dave@example.com',
subject: 'Empty',
message: '',
text: '',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).toBe('')
})
it('returns empty content when both message and text are null', () => {
const mail = {
source: 'eve@example.com',
originalSource: 'eve@example.com',
subject: 'Null',
message: null,
text: null,
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).toBe('')
})
it('parses "Name <email>" format for sender', () => {
const mail = {
source: 'Alice Smith <alice@example.com>',
originalSource: 'alice@example.com',
subject: 'Test',
message: '<p>body</p>',
text: '',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.toName).toBe('Alice Smith')
expect(result.toMail).toBe('alice@example.com')
})
it('uses originalSource as toMail when source is plain email', () => {
const mail = {
source: 'plain@example.com',
originalSource: 'plain@example.com',
subject: 'Test',
message: '',
text: 'body',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.toName).toBe('')
expect(result.toMail).toBe('plain@example.com')
})
it('defaults toMail to empty string when originalSource is null', () => {
const mail = {
source: 'plain@example.com',
originalSource: null,
subject: 'Test',
message: '',
text: 'body',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.toMail).toBe('')
})
it('formats subject with reply label', () => {
const mail = {
source: 'test@example.com',
originalSource: 'test@example.com',
subject: 'Original Subject',
message: '',
text: '',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.subject).toBe('Reply: Original Subject')
})
it('uses html contentType for HTML email reply', () => {
const mail = {
source: 'test@example.com',
originalSource: 'test@example.com',
subject: 'Test',
message: '<p>html</p>',
text: 'plain',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.contentType).toBe('html')
})
it('uses rich contentType for plain text email reply', () => {
const mail = {
source: 'test@example.com',
originalSource: 'test@example.com',
subject: 'Test',
message: '',
text: 'plain',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.contentType).toBe('rich')
})
it('strips script tags from HTML reply content (XSS)', () => {
const mail = {
source: 'attacker@example.com',
originalSource: 'attacker@example.com',
subject: 'XSS',
message: '<p>Hello</p><script>alert("xss")</script><p>World</p>',
text: '',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).not.toContain('<script>')
expect(result.content).toContain('<p>Hello</p>')
expect(result.content).toContain('<p>World</p>')
})
it('strips event handlers from HTML reply content (XSS)', () => {
const mail = {
source: 'attacker@example.com',
originalSource: 'attacker@example.com',
subject: 'XSS',
message: '<img src=x onerror="alert(1)"><p>Text</p>',
text: '',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).not.toContain('onerror')
expect(result.content).toContain('<p>Text</p>')
})
it('escapes HTML chars in plain text reply content', () => {
const mail = {
source: 'user@example.com',
originalSource: 'user@example.com',
subject: 'Test',
message: '',
text: 'a < b & c > d',
}
const result = buildReplyModel(mail, 'Reply')
expect(result.content).toContain('a &lt; b &amp; c &gt; d')
expect(result.content).not.toContain('a < b')
})
})
describe('buildForwardModel', () => {
it('uses html contentType when message is present', () => {
const mail = {
subject: 'FW Test',
message: '<p>HTML content</p>',
text: 'Plain content',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.contentType).toBe('html')
expect(result.content).toBe('<p>HTML content</p>')
})
it('uses text contentType when message is empty', () => {
const mail = {
subject: 'FW Test',
message: '',
text: 'Plain text only',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.contentType).toBe('text')
expect(result.content).toBe('Plain text only')
})
it('uses text contentType when message is null', () => {
const mail = {
subject: 'FW Test',
message: null,
text: 'Fallback text',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.contentType).toBe('text')
expect(result.content).toBe('Fallback text')
})
it('formats subject with forward label', () => {
const mail = {
subject: 'Original',
message: '',
text: '',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.subject).toBe('Forward: Original')
})
it('strips script tags from HTML forward content (XSS)', () => {
const mail = {
subject: 'XSS Test',
message: '<div>Safe</div><script>alert("xss")</script>',
text: '',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.content).not.toContain('<script>')
expect(result.content).toContain('<div>Safe</div>')
})
it('strips event handlers from HTML forward content (XSS)', () => {
const mail = {
subject: 'XSS Test',
message: '<img src=x onerror="alert(1)"><b>Bold</b>',
text: '',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.content).not.toContain('onerror')
expect(result.content).toContain('<b>Bold</b>')
})
it('escapes special chars in plain text forward content', () => {
const mail = {
subject: 'FW Text',
message: '',
text: 'a < b & c > d',
}
const result = buildForwardModel(mail, 'Forward')
expect(result.contentType).toBe('text')
expect(result.content).toBe('a &lt; b &amp; c &gt; d')
})
})

View File

@@ -14,12 +14,13 @@ export async function processItem(item) {
item.source = parsedEmail.sender || item.source;
item.subject = parsedEmail.subject || '';
item.message = parsedEmail.body_html || parsedEmail.text || '';
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
[a_item.content],
{ type: a_item.content_type || 'application/octet-stream' }
))
const blob = new Blob(
[a_item.content],
{ type: a_item.content_type || 'application/octet-stream' }
);
const blob_url = URL.createObjectURL(blob);
if (a_item.content_id && a_item.content_id.length > 0) {
item.message = item.message.replace(`cid:${a_item.content_id}`, blob_url);
}
@@ -27,7 +28,8 @@ export async function processItem(item) {
id: a_item.content_id || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.content_id || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
url: blob_url,
blob: blob
}
}) || [];
} catch (error) {
@@ -46,12 +48,13 @@ export async function processItem(item) {
}
item.subject = parsedEmail.subject || 'No Subject';
item.message = parsedEmail.html || parsedEmail.text || item.raw;
item.text = parsedEmail.text || '';
item.attachments = parsedEmail.attachments?.map((a_item) => {
const blob_url = URL.createObjectURL(
new Blob(
[a_item.content],
{ type: a_item.mimeType || 'application/octet-stream' }
))
const blob = new Blob(
[a_item.content],
{ type: a_item.mimeType || 'application/octet-stream' }
);
const blob_url = URL.createObjectURL(blob)
if (a_item.contentId && a_item.contentId.length > 0) {
item.message = item.message.replace(`cid:${a_item.contentId}`, blob_url);
}
@@ -59,7 +62,8 @@ export async function processItem(item) {
id: a_item.contentId || Math.random().toString(36).substring(2, 15),
filename: a_item.filename || a_item.contentId || "",
size: humanFileSize(a_item.content?.length || 0),
url: blob_url
url: blob_url,
blob: blob
}
}) || [];
} catch (error) {

View File

@@ -0,0 +1,30 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { useGlobalState } from '../store';
const { browserFingerprint } = useGlobalState();
/**
* Get browser fingerprint
* Uses cached value from global state if available to avoid unnecessary computation
* @returns Fingerprint visitor ID, or 'ERROR' if failed
*/
export const getFingerprint = async (): Promise<string> => {
// Return cached fingerprint if available
if (browserFingerprint.value) {
return browserFingerprint.value;
}
try {
const fp = await FingerprintJS.load();
const result = await fp.get();
browserFingerprint.value = result.visitorId;
return browserFingerprint.value;
} catch (error) {
console.error('Failed to get fingerprint:', error);
// Return special error value to prevent blocking requests
const errorValue = 'ERROR';
browserFingerprint.value = errorValue;
return errorValue;
}
};

View File

@@ -0,0 +1,30 @@
export const hashPassword = async (password: string) => {
// user crypto to hash password
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
}
export const getRouterPathWithLang = (path: string, lang: string) => {
if (!lang || lang === 'zh') {
return path;
}
return `/${lang}${path}`;
}
export const utcToLocalDate = (utcDate: string, useUTCDate: boolean) => {
const utcDateString = `${utcDate} UTC`;
if (useUTCDate) {
return utcDateString;
}
try {
const date = new Date(utcDateString);
// if invalid date string
if (isNaN(date.getTime())) return utcDateString;
return date.toLocaleString();
} catch (e) {
console.error(e);
}
return utcDateString;
}

View File

@@ -0,0 +1,68 @@
import DOMPurify from 'dompurify';
/**
* HTML-escape special characters for plain text content.
*/
function escapeHtml(str) {
const text = String(str ?? '');
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
/**
* Sanitize mail content: HTML-escape plain text, whitelist-sanitize HTML.
*/
function sanitizeContent(mail) {
if (mail.message) {
return DOMPurify.sanitize(mail.message);
}
if (mail.text) {
return escapeHtml(mail.text);
}
return '';
}
/**
* Build the send-mail model for replying to an email.
* @param {Object} mail - The mail object (curMail)
* @param {string} replyLabel - Translated "Reply" label
* @returns {Object} Fields to assign onto sendMailModel
*/
export function buildReplyModel(mail, replyLabel) {
const emailRegex = /(.+?) <(.+?)>/;
let toMail = mail.originalSource || '';
let toName = "";
const match = emailRegex.exec(mail.source);
if (match) {
toName = match[1];
toMail = match[2];
}
const safeContent = sanitizeContent(mail);
return {
toName,
toMail,
subject: `${replyLabel}: ${mail.subject}`,
contentType: mail.message ? 'html' : 'rich',
content: safeContent
? `<p><br></p><blockquote>${safeContent}</blockquote><p><br></p>`
: '',
};
}
/**
* Build the send-mail model for forwarding an email.
* @param {Object} mail - The mail object (curMail)
* @param {string} forwardLabel - Translated "Forward" label
* @returns {Object} Fields to assign onto sendMailModel
*/
export function buildForwardModel(mail, forwardLabel) {
return {
subject: `${forwardLabel}: ${mail.subject}`,
contentType: mail.message ? 'html' : 'text',
content: sanitizeContent(mail),
};
}

View File

@@ -1,8 +1,12 @@
<script setup>
import { onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { getRouterPathWithLang, hashPassword } from '../utils'
import Turnstile from '../components/Turnstile.vue'
import SenderAccess from './admin/SenderAccess.vue'
import Statistics from "./admin/Statistics.vue"
@@ -10,102 +14,318 @@ import SendBox from './admin/SendBox.vue';
import Account from './admin/Account.vue';
import CreateAccount from './admin/CreateAccount.vue';
import AccountSettings from './admin/AccountSettings.vue';
import UserManagement from './admin/UserManagement.vue';
import UserSettings from './admin/UserSettings.vue';
import UserOauth2Settings from './admin/UserOauth2Settings.vue';
import RoleAddressConfig from './admin/RoleAddressConfig.vue';
import Mails from './admin/Mails.vue';
import MailsUnknow from './admin/MailsUnknow.vue';
import About from './common/About.vue';
import Maintenance from './admin/Maintenance.vue';
import DatabaseManager from './admin/DatabaseManager.vue';
import Appearance from './common/Appearance.vue';
import Telegram from './admin/Telegram.vue';
import Webhook from './admin/Webhook.vue';
import MailWebhook from './admin/MailWebhook.vue';
import WorkerConfig from './admin/WorkerConfig.vue';
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
import AiExtractSettings from './admin/AiExtractSettings.vue';
const {
localeCache, adminAuth, showAdminAuth, adminTab, loading
adminAuth, showAdminAuth, adminTab, loading,
globalTabplacement, showAdminPage, userSettings,
openSettings
} = useGlobalState()
const message = useMessage()
const router = useRouter()
const SendMail = defineAsyncComponent(() => {
loading.value = true;
return import('./admin/SendMail.vue')
.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?.();
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
const showLogoutModal = ref(false)
const handleLogout = async () => {
// 清空管理员认证
adminAuth.value = '';
// 重置管理员相关状态
showAdminAuth.value = false;
adminTab.value = 'account';
// 显示成功提示并跳转
message.success(t('logoutSuccess'));
await router.push(getRouterPathWithLang('/', locale.value));
}
const { t, locale } = useI18n({
messages: {
en: {
accessHeader: 'Admin Password',
accessTip: 'Please enter the admin password',
mails: 'Emails',
sendMail: 'Send Mail',
qucickSetup: 'Quick Setup',
account: 'Account',
account_create: 'Create Account',
account_settings: 'Account Settings',
user: 'User',
user_management: 'User Management',
user_settings: 'User Settings',
userOauth2Settings: 'Oauth2 Settings',
roleAddressConfig: 'Role Address Config',
unknow: 'Mails with unknow receiver',
senderAccess: 'Sender Access Control',
sendBox: 'Send Box',
telegram: 'Telegram Bot',
webhookSettings: 'Webhook Settings',
statistics: 'Statistics',
maintenance: 'Maintenance',
database: 'Database',
workerconfig: 'Worker Config',
ipBlacklistSettings: 'IP Blacklist',
aiExtractSettings: 'AI Extract Settings',
appearance: 'Appearance',
about: 'About',
ok: 'OK',
mailWebhook: 'Mail Webhook',
adminAccount: 'Admin',
loginMethod: 'Login Method',
loginViaPassword: 'Admin Password Login',
loginViaUserAdmin: 'User Admin Permission',
loginViaDisabledCheck: 'Disabled Password Check',
logout: 'Logout',
logoutConfirmTitle: 'Confirm Logout',
logoutConfirmContent: 'Are you sure you want to logout from admin panel?',
confirm: 'Confirm',
logoutSuccess: 'Logout successful',
},
zh: {
accessHeader: 'Admin 密码',
accessTip: '请输入 Admin 密码',
mails: '邮件',
sendMail: '发送邮件',
qucickSetup: '快速设置',
account: '账号',
account_create: '创建账号',
account_settings: '账号设置',
user: '用户',
user_management: '用户管理',
user_settings: '用户设置',
userOauth2Settings: 'Oauth2 设置',
roleAddressConfig: '角色地址配置',
unknow: '无收件人邮件',
senderAccess: '发件权限控制',
sendBox: '发件箱',
telegram: '电报机器人',
webhookSettings: 'Webhook 设置',
statistics: '统计',
maintenance: '维护',
database: '数据库',
workerconfig: 'Worker 配置',
ipBlacklistSettings: 'IP 黑名单',
aiExtractSettings: 'AI 提取设置',
appearance: '外观',
about: '关于',
ok: '确定',
mailWebhook: '邮件 Webhook',
adminAccount: '管理员',
loginMethod: '登录方式',
loginViaPassword: 'Admin 密码登录',
loginViaUserAdmin: '用户管理员权限',
loginViaDisabledCheck: '已禁用密码检查',
logout: '退出登录',
logoutConfirmTitle: '确认退出',
logoutConfirmContent: '确定要退出管理员面板吗?',
confirm: '确认',
logoutSuccess: '退出成功',
}
}
});
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
const showAdminPasswordModal = computed(() => !showAdminPage.value || showAdminAuth.value)
const tmpAdminAuth = ref('')
// 判断是否通过 admin password 登录(而非用户管理员权限)
const isAdminPasswordLogin = computed(() => !!adminAuth.value)
// 获取当前登录方式
const currentLoginMethod = computed(() => {
if (adminAuth.value) {
return t('loginViaPassword');
} else if (userSettings.value.is_admin) {
return t('loginViaUserAdmin');
} else if (openSettings.value.disableAdminPasswordCheck) {
return t('loginViaDisabledCheck');
}
return '';
})
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);
})
</script>
<template>
<div>
<n-modal v-model:show="showAdminAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<div v-if="userSettings.fetched">
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
preset="dialog" :title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="adminAuth" type="textarea" :autosize="{ minRows: 3 }" />
<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') }}
</n-button>
</template>
</n-modal>
<Statistics />
<n-tabs type="card" v-model:value="adminTab">
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane>
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
<WorkerConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="account" :tab="t('account')">
<Account />
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="account" :tab="t('account')">
<Account />
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="ipBlacklistSettings" :tab="t('ipBlacklistSettings')">
<IpBlacklistSettings />
</n-tab-pane>
<n-tab-pane name="aiExtractSettings" :tab="t('aiExtractSettings')">
<AiExtractSettings />
</n-tab-pane>
<n-tab-pane name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="account_create" :tab="t('account_create')">
<CreateAccount />
</n-tab-pane>
<n-tab-pane name="account_settings" :tab="t('account_settings')">
<AccountSettings />
<n-tab-pane name="user" :tab="t('user')">
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="user_management" :tab="t('user_management')">
<UserManagement />
</n-tab-pane>
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettings />
</n-tab-pane>
<n-tab-pane name="userOauth2Settings" :tab="t('userOauth2Settings')">
<UserOauth2Settings />
</n-tab-pane>
<n-tab-pane name="roleAddressConfig" :tab="t('roleAddressConfig')">
<RoleAddressConfig />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="mails" :tab="t('mails')">
<Mails />
</n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')">
<MailsUnknow />
</n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
</n-tab-pane>
<n-tab-pane name="sendMail" :tab="t('sendMail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="mailWebhook" :tab="t('mailWebhook')">
<MailWebhook />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="unknow" :tab="t('unknow')">
<MailsUnknow />
<n-tab-pane name="telegram" :tab="t('telegram')">
<Telegram />
</n-tab-pane>
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
<SenderAccess />
</n-tab-pane>
<n-tab-pane name="sendBox" :tab="t('sendBox')">
<SendBox />
<n-tab-pane name="statistics" :tab="t('statistics')">
<Statistics />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<Maintenance />
<n-tabs type="bar" justify-content="center" animated>
<n-tab-pane name="database" :tab="t('database')">
<DatabaseManager />
</n-tab-pane>
<n-tab-pane name="workerconfig" :tab="t('workerconfig')">
<WorkerConfig />
</n-tab-pane>
<n-tab-pane name="maintenance" :tab="t('maintenance')">
<Maintenance />
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance />
</n-tab-pane>
<n-tab-pane name="adminAccount" :tab="t('adminAccount')">
<div style="display: flex; justify-content: center; padding: 20px;">
<n-card style="width: 600px;">
<n-space vertical>
<n-text strong>{{ t('loginMethod') }}</n-text>
<n-text>{{ currentLoginMethod }}</n-text>
<n-divider v-if="isAdminPasswordLogin" />
<n-button v-if="isAdminPasswordLogin" type="warning" @click="showLogoutModal = true" block>
{{ t('logout') }}
</n-button>
</n-space>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs>
<n-modal v-model:show="showLogoutModal" preset="dialog" :title="t('logoutConfirmTitle')">
<p>{{ t('logoutConfirmContent') }}</p>
<template #action>
<n-button :loading="loading" @click="handleLogout" size="small" tertiary type="warning">
{{ t('confirm') }}
</n-button>
</template>
</n-modal>
</div>
</template>

View File

@@ -1,11 +1,10 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../store'
const { localeCache, openSettings } = useGlobalState()
const { openSettings } = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
copyright: "Copyright"
@@ -22,9 +21,14 @@ const { t } = useI18n({
<div>
<n-divider class="footer-divider" />
<div style="text-align: center; padding: 20px">
<n-text depth="3">
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }} {{ openSettings.copyright }}
</n-text>
<n-space justify="center">
<n-text depth="3">
{{ t('copyright') }} © 2023-{{ new Date().getFullYear() }}
</n-text>
<n-text depth="3">
<div v-html="openSettings.copyright"></div>
</n-text>
</n-space>
</div>
</div>
</template>

View File

@@ -1,67 +1,78 @@
<script setup>
import useClipboard from 'vue-clipboard3'
import { ref, h, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useHead } from '@unhead/vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useIsMobile } from '../utils/composables'
import {
DarkModeFilled, LightModeFilled, MenuFilled,
AdminPanelSettingsFilled, SendFilled
AdminPanelSettingsFilled, MonitorHeartFilled
} from '@vicons/material'
import { GithubAlt, Language, User, Home, Copy } from '@vicons/fa'
import Login from './Login.vue'
import { GithubAlt, Language, User, Home } from '@vicons/fa'
import { useGlobalState } from '../store'
import { api } from '../api'
const { toClipboard } = useClipboard()
import { getRouterPathWithLang, hashPassword } from '../utils'
import Turnstile from '../components/Turnstile.vue'
const message = useMessage()
const notification = useNotification()
const {
jwt, localeCache, toggleDark, isDark, settings, showPassword,
showAuth, adminAuth, auth, loading
toggleDark, isDark, isTelegram, showAdminPage,
showAuth, auth, loading, openSettings, userSettings
} = useGlobalState()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const isAdminRoute = computed(() => route.path.includes('admin'))
const showMobileMenu = ref(false)
const menuValue = computed(() => {
if (route.path.includes("user")) return "user";
if (route.path.includes("admin")) return "admin";
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?.();
}
}
const changeLocale = (locale) => {
localeCache.value = locale;
location.reload()
const changeLocale = async (lang) => {
if (lang == 'zh') {
await router.push(route.fullPath.replace('/en', ''));
} else {
await router.push(`/${lang}${route.fullPath}`);
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
const { locale, t } = useI18n({
messages: {
en: {
title: 'Cloudflare Temp Email',
dark: 'Dark',
light: 'Light',
accessHeader: 'Access Password',
accessTip: 'Please enter the correct password',
accessTip: 'Please enter the correct access password',
home: 'Home',
menu: 'Menu',
user: 'User',
sendMail: 'Send Mail',
yourAddress: 'Your email address is',
status: 'Status',
ok: 'OK',
copy: 'Copy',
copied: 'Copied',
fetchAddressError: 'Login password is invalid or account not exist, it may be network connection issue, please try again later.',
mailV1Alert: 'You have some mails in v1, please click here to login and visit your history mails.',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
},
zh: {
title: 'Cloudflare 临时邮件',
@@ -72,36 +83,31 @@ const { t } = useI18n({
home: '主页',
menu: '菜单',
user: '用户',
sendMail: '发送邮件',
yourAddress: '你的邮箱地址是',
status: '状态',
ok: '确定',
copy: '复制',
copied: '已复制',
fetchAddressError: '登录密码无效或账号不存在,也可能是网络连接异常,请稍后再尝试。',
mailV1Alert: '你有一些 v1 版本的邮件,请点击此处登录查看。',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
}
}
});
const showUserMenu = computed(() => !!settings.value.address)
const version = import.meta.env.PACKAGE_VERSION ? `v${import.meta.env.PACKAGE_VERSION}` : "";
const menuOptions = computed(() => [
{
label: () => h(
NButton,
label: () => h(NButton,
{
text: true,
size: "small",
type: menuValue.value == "home" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/'); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang('/', locale.value));
showMobileMenu.value = false;
}
},
{
default: () => t('home'),
icon: () => h(NIcon, { component: Home })
}
),
}),
key: "home"
},
{
@@ -110,16 +116,20 @@ const menuOptions = computed(() => [
{
text: true,
size: "small",
type: menuValue.value == "user" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push('/admin'); showMobileMenu.value = false; }
onClick: async () => {
await router.push(getRouterPathWithLang("/user", locale.value));
showMobileMenu.value = false;
}
},
{
default: () => "Admin",
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
}
),
show: !!adminAuth.value,
key: "admin"
key: "user",
show: !isTelegram.value
},
{
label: () => h(
@@ -127,16 +137,22 @@ const menuOptions = computed(() => [
{
text: true,
size: "small",
type: menuValue.value == "admin" ? "primary" : "default",
style: "width: 100%",
onClick: () => { router.push("/user"); showMobileMenu.value = false; }
onClick: async () => {
loading.value = true;
await router.push(getRouterPathWithLang('/admin', locale.value));
loading.value = false;
showMobileMenu.value = false;
}
},
{
default: () => t('user'),
icon: () => h(NIcon, { component: User }),
default: () => "Admin",
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
}
),
show: showUserMenu.value,
key: "user",
show: showAdminPage.value,
key: "admin"
},
{
label: () => h(
@@ -163,13 +179,13 @@ const menuOptions = computed(() => [
text: true,
size: "small",
style: "width: 100%",
onClick: () => {
localeCache.value == 'zh' ? changeLocale('en') : changeLocale('zh');
onClick: async () => {
locale.value == 'zh' ? await changeLocale('en') : await changeLocale('zh');
showMobileMenu.value = false;
}
},
{
default: () => localeCache.value == 'zh' ? "English" : "中文",
default: () => locale.value == 'zh' ? "English" : "中文",
icon: () => h(
NIcon, { component: Language }
)
@@ -177,6 +193,25 @@ const menuOptions = computed(() => [
),
key: "lang"
},
{
label: () => h(
NButton,
{
text: true,
size: "small",
style: "width: 100%",
tag: "a",
target: "_blank",
href: openSettings.value?.statusUrl,
},
{
default: () => t('status'),
icon: () => h(NIcon, { component: MonitorHeartFilled })
}
),
show: !!openSettings.value?.statusUrl,
key: "status"
},
{
label: () => h(
NButton,
@@ -189,26 +224,46 @@ const menuOptions = computed(() => [
href: "https://github.com/dreamhunter2333/cloudflare_temp_email",
},
{
default: () => "Github",
default: () => version || "Github",
icon: () => h(NIcon, { component: GithubAlt })
}
),
show: openSettings.value?.showGithub,
key: "github"
}
]);
const copy = async () => {
try {
await toClipboard(settings.value.address)
message.success(t('copied'));
} catch (e) {
message.error(e.message || "error");
useHead({
title: () => openSettings.value.title || t('title'),
meta: [
{ name: "description", content: openSettings.value.description || t('title') },
]
});
const logoClickCount = ref(0);
const logoClick = async () => {
if (route.path.includes("admin")) {
logoClickCount.value = 0;
return;
}
if (logoClickCount.value >= 5) {
logoClickCount.value = 0;
message.info("Change to admin Page");
loading.value = true;
await router.push(getRouterPathWithLang('/admin', locale.value));
loading.value = false;
} else {
logoClickCount.value++;
}
if (logoClickCount.value > 0) {
message.info(`Click ${5 - logoClickCount.value + 1} times to enter the admin page`);
}
}
onMounted(async () => {
await api.getOpenSettings(message);
await api.getSettings();
await api.getOpenSettings(message, notification);
// make sure user_id is fetched
if (!userSettings.value.user_id) await api.getUserSettings(message);
});
</script>
@@ -216,14 +271,16 @@ onMounted(async () => {
<div>
<n-page-header>
<template #title>
<h3>{{ t('title') }}</h3>
<h3>{{ openSettings.title || t('title') }}</h3>
</template>
<template #avatar>
<n-avatar style="margin-left: 10px;" src="/logo.png" />
<div @click="logoClick">
<n-avatar style="margin-left: 10px;" src="/logo.png" />
</div>
</template>
<template #extra>
<n-space>
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" />
<n-menu v-if="!isMobile" mode="horizontal" :options="menuOptions" responsive />
<n-button v-else :text="true" @click="showMobileMenu = !showMobileMenu" style="margin-right: 10px;">
<template #icon>
<n-icon :component="MenuFilled" />
@@ -238,53 +295,11 @@ onMounted(async () => {
<n-menu :options="menuOptions" />
</n-drawer-content>
</n-drawer>
<div v-if="!isAdminRoute">
<n-card v-if="!settings.fetched">
<n-skeleton style="height: 50vh" />
</n-card>
<div v-else-if="settings.address">
<n-alert v-if="settings.has_v1_mails" type="warning" show-icon closable>
<span>
<n-button tag="a" target="_blank" tertiary type="info" size="small"
href="https://mail-v1.awsl.uk">
<b>{{ t('mailV1Alert') }} </b>
</n-button>
</span>
</n-alert>
<n-alert type="info" show-icon>
<span>
<b>{{ t('yourAddress') }} <b>{{ settings.address }}</b></b>
<n-button style="margin-left: 10px" @click="router.push('/send')" size="small" tertiary
type="primary">
<n-icon :component="SendFilled" /> {{ t('sendMail') }}
</n-button>
<n-button style="margin-left: 10px" @click="copy" size="small" tertiary type="primary">
<n-icon :component="Copy" /> {{ t('copy') }}
</n-button>
</span>
</n-alert>
</div>
<div v-else class="center">
<n-card style="max-width: 600px;">
<n-alert v-if="jwt" type="warning" show-icon>
<span>{{ t('fetchAddressError') }}</span>
</n-alert>
<Login />
</n-card>
</div>
</div>
<n-modal v-model:show="showPassword" preset="dialog" :title="t('password')">
<span>
<p>{{ t("passwordTip") }}</p>
</span>
<n-card>
<b>{{ jwt }}</b>
</n-card>
</n-modal>
<n-modal v-model:show="showAuth" :closable="false" :closeOnEsc="false" :maskClosable="false" preset="dialog"
:title="t('accessHeader')">
<p>{{ t('accessTip') }}</p>
<n-input v-model:value="auth" type="textarea" :autosize="{ minRows: 3 }" />
<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

@@ -1,22 +1,191 @@
<script setup>
import MailBox from '../components/MailBox.vue';
import { defineAsyncComponent, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useGlobalState } from '../store'
import { api } from '../api'
import { useIsMobile } from '../utils/composables'
import { FullscreenExitOutlined } from '@vicons/material'
const { settings, openSettings } = useGlobalState()
import AddressBar from './index/AddressBar.vue';
import MailBox from '../components/MailBox.vue';
import SendBox from '../components/SendBox.vue';
import AutoReply from './index/AutoReply.vue';
import AccountSettings from './index/AccountSettings.vue';
import Appearance from './common/Appearance.vue';
import Webhook from './index/Webhook.vue';
import Attachment from './index/Attachment.vue';
import About from './common/About.vue';
import SimpleIndex from './index/SimpleIndex.vue';
const { loading, settings, openSettings, indexTab, globalTabplacement, useSimpleIndex } = useGlobalState()
const message = useMessage()
const route = useRoute()
const isMobile = useIsMobile()
const SendMail = defineAsyncComponent(() => {
loading.value = true;
return import('./index/SendMail.vue')
.finally(() => loading.value = false);
});
const { t } = useI18n({
messages: {
en: {
mailbox: 'Mail Box',
sendbox: 'Send Box',
sendmail: 'Send Mail',
auto_reply: 'Auto Reply',
accountSettings: 'Account Settings',
appearance: 'Appearance',
about: 'About',
s3Attachment: 'S3 Attachment',
saveToS3Success: 'save to s3 success',
webhookSettings: 'Webhook Settings',
query: 'Query',
enterSimpleMode: 'Simple Mode',
},
zh: {
mailbox: '收件箱',
sendbox: '发件箱',
sendmail: '发送邮件',
auto_reply: '自动回复',
accountSettings: '账户',
appearance: '外观',
about: '关于',
s3Attachment: 'S3附件',
saveToS3Success: '保存到s3成功',
webhookSettings: 'Webhook 设置',
query: '查询',
enterSimpleMode: '极简模式',
}
}
});
const fetchMailData = async (limit, offset) => {
if (mailIdQuery.value > 0) {
const singleMail = await api.fetch(`/api/mail/${mailIdQuery.value}`);
if (singleMail) return { results: [singleMail], count: 1 };
return { results: [], count: 0 };
}
return await api.fetch(`/api/mails?limit=${limit}&offset=${offset}`);
};
const deleteMail = async (curMailId) => {
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
};
const deleteSenboxMail = async (curMailId) => {
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
};
const fetchSenboxData = async (limit, offset) => {
return await api.fetch(`/api/sendbox?limit=${limit}&offset=${offset}`);
};
const saveToS3 = async (mail_id, filename, blob) => {
try {
const { url } = await api.fetch(`/api/attachment/put_url`, {
method: 'POST',
body: JSON.stringify({ key: `${mail_id}/${filename}` })
});
// upload to s3 by formdata
const formData = new FormData();
formData.append(filename, blob);
await fetch(url, {
method: 'PUT',
body: formData
});
message.success(t('saveToS3Success'));
} catch (error) {
console.error(error);
message.error(error.message || "save to s3 error");
}
}
const mailBoxKey = ref("")
const mailIdQuery = ref("")
const showMailIdQuery = ref(false)
const queryMail = () => {
mailBoxKey.value = Date.now();
}
watch(route, () => {
if (!route.query.mail_id) {
showMailIdQuery.value = false;
mailIdQuery.value = "";
queryMail();
}
})
onMounted(() => {
if (route.query.mail_id) {
showMailIdQuery.value = true;
mailIdQuery.value = route.query.mail_id;
queryMail();
}
})
</script>
<template>
<div v-if="settings.address">
<MailBox :showEMailTo="false" :showReply="true" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" />
<div>
<div v-if="useSimpleIndex">
<SimpleIndex />
</div>
<div v-else>
<AddressBar />
<n-tabs v-if="settings.address" type="card" v-model:value="indexTab" :placement="globalTabplacement">
<template #prefix v-if="!isMobile">
<n-button @click="useSimpleIndex = true" tertiary size="small">
<template #icon>
<n-icon>
<FullscreenExitOutlined />
</n-icon>
</template>
{{ t('enterSimpleMode') }}
</n-button>
</template>
<n-tab-pane name="mailbox" :tab="t('mailbox')">
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
<n-input-group>
<n-input v-model:value="mailIdQuery" />
<n-button @click="queryMail" type="primary" tertiary>
{{ t('query') }}
</n-button>
</n-input-group>
</div>
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
:deleteMail="deleteSenboxMail" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
<SendMail />
</n-tab-pane>
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
<AccountSettings />
</n-tab-pane>
<n-tab-pane name="appearance" :tab="t('appearance')">
<Appearance :showUseSimpleIndex="true" />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableWebhook" name="webhook" :tab="t('webhookSettings')">
<Webhook />
</n-tab-pane>
<n-tab-pane v-if="openSettings.isS3Enabled" name="s3_attachment" :tab="t('s3Attachment')">
<Attachment />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableIndexAbout" name="about" :tab="t('about')">
<About />
</n-tab-pane>
</n-tabs>
</div>
</div>
</template>

View File

@@ -1,166 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import AdminContact from './admin/AdminContact.vue'
import { useGlobalState } from '../store'
import { api } from '../api'
const message = useMessage()
const {
jwt, localeCache, loading, openSettings, showPassword
} = useGlobalState()
const tabValue = ref('signin')
const password = ref('')
const emailName = ref("")
const emailDomain = ref("")
const login = async () => {
if (!password.value) {
message.error(t('passwordInput'));
return;
}
try {
jwt.value = password.value;
await api.getSettings()
location.reload()
} catch (error) {
message.error(error.message || "error");
}
}
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
login: 'Login',
pleaseGetNewEmail: 'Please login or click "Get New Email" button to get a new email address',
getNewEmail: 'Get New Email',
getNewEmailTip1: 'Please input the email you want to use. only allow ., a-z, A-Z and 0-9',
getNewEmailTip2: 'Levaing it blank will generate a random email address.',
getNewEmailTip3: 'You can choose a domain from the dropdown list.',
password: 'Password',
ok: 'OK',
generateName: 'Generate Fake Name',
help: 'Help',
passwordInput: 'Please input the password',
},
zh: {
login: '登录',
pleaseGetNewEmail: '请"登录"或点击 "获取新邮箱" 按钮来获取一个新的邮箱地址',
getNewEmail: '注册新邮箱',
getNewEmailTip1: '请输入你想要使用的邮箱地址, 只允许 ., a-z, A-Z, 0-9',
getNewEmailTip2: '留空将会生成一个随机的邮箱地址。',
getNewEmailTip3: '你可以从下拉列表中选择一个域名。',
password: '密码',
ok: '确定',
generateName: '生成随机名字',
help: '帮助',
passwordInput: '请输入密码',
}
}
});
const generateNameLoading = ref(false);
const generateName = async () => {
try {
generateNameLoading.value = true;
const { faker } = await import('https://esm.sh/@faker-js/faker');
emailName.value = faker.person
.fullName()
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '')
.toLowerCase();
} catch (error) {
message.error(error.message || "error");
} finally {
generateNameLoading.value = false;
}
};
const newEmail = async () => {
try {
const res = await api.fetch(
`/api/new_address`
+ `?name=${emailName.value || ''}`
+ `&domain=${emailDomain.value || ''}`
);
jwt.value = res["jwt"];
await api.getSettings();
showPassword.value = true;
} catch (error) {
message.error(error.message || "error");
}
};
onMounted(async () => {
emailDomain.value = openSettings.value.domains ? openSettings.value.domains[0].value : "";
});
</script>
<template>
<div>
<n-tabs v-model:value="tabValue" size="large" justify-content="space-evenly">
<n-tab-pane name="signin" :tab="t('login')">
<n-form>
<n-form-item-row :label="t('password')" required>
<n-input v-model:value="password" type="textarea" :autosize="{ minRows: 3 }" />
</n-form-item-row>
<n-button @click="login" :loading="loading" type="primary" block secondary strong>
{{ t('login') }}
</n-button>
<n-button v-if="openSettings.enableUserCreateEmail" @click="tabValue = 'register'" block secondary
strong>
{{ t('getNewEmail') }}
</n-button>
</n-form>
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableUserCreateEmail" name="register" :tab="t('getNewEmail')">
<n-spin :show="generateNameLoading">
<n-form>
<span>
<p>{{ t("getNewEmailTip1") }}</p>
<p>{{ t("getNewEmailTip2") }}</p>
<p>{{ t("getNewEmailTip3") }}</p>
</span>
<n-button @click="generateName" style="margin-bottom: 10px;">
{{ t('generateName') }}
</n-button>
<n-input-group>
<n-input-group-label v-if="openSettings.prefix">
{{ openSettings.prefix }}
</n-input-group-label>
<n-input v-model:value="emailName" />
<n-input-group-label>@</n-input-group-label>
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
:options="openSettings.domains" />
</n-input-group>
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
{{ t('ok') }}
</n-button>
</n-form>
</n-spin>
</n-tab-pane>
<n-tab-pane name="help" :tab="t('help')">
<n-alert type="info" show-icon>
<span>{{ t('pleaseGetNewEmail') }}</span>
</n-alert>
<AdminContact />
</n-tab-pane>
</n-tabs>
</div>
</template>
<style scoped>
.n-alert {
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}
.n-form .n-button {
margin-top: 10px;
}
</style>

View File

@@ -1,28 +1,31 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useStorage } from '@vueuse/core'
import { useGlobalState } from '../store'
import AutoReply from './user/AutoReply.vue';
import SendBox from './send/SendBox.vue';
import Account from './user/Account.vue';
import AddressMangement from './user/AddressManagement.vue';
import UserSettingsPage from './user/UserSettings.vue';
import UserBar from './user/UserBar.vue';
import BindAddress from './user/BindAddress.vue';
import UserMailBox from './user/UserMailBox.vue';
const { localeCache, settings, openSettings } = useGlobalState()
const userTab = useStorage('userTab', 'account')
const {
userTab, globalTabplacement, userSettings
} = useGlobalState()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
sendbox: 'Send Box',
auto_reply: 'Auto Reply',
account: 'Account',
address_management: 'Address Management',
user_mail_box_tab: 'Mail Box',
user_settings: 'User Settings',
bind_address: 'Bind Mail Address',
},
zh: {
sendbox: '发件箱',
auto_reply: '自动回复',
account: '账户',
address_management: '地址管理',
user_mail_box_tab: '收件箱',
user_settings: '用户设置',
bind_address: '绑定邮箱地址',
}
}
});
@@ -30,16 +33,20 @@ const { t } = useI18n({
</script>
<template>
<div v-if="settings.address">
<n-tabs type="card" v-model:value="userTab">
<n-tab-pane name="account" :tab="t('account')">
<Account />
<div>
<UserBar />
<n-tabs v-if="userSettings.user_email" type="card" v-model:value="userTab" :placement="globalTabplacement">
<n-tab-pane name="address_management" :tab="t('address_management')">
<AddressMangement />
</n-tab-pane>
<n-tab-pane name="sendbox" :tab="t('sendbox')">
<SendBox />
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
<UserMailBox />
</n-tab-pane>
<n-tab-pane v-if="openSettings.enableAutoReply" name="auto_reply" :tab="t('auto_reply')">
<AutoReply />
<n-tab-pane name="user_settings" :tab="t('user_settings')">
<UserSettingsPage />
</n-tab-pane>
<n-tab-pane name="bind_address" :tab="t('bind_address')">
<BindAddress />
</n-tab-pane>
</n-tabs>
</div>

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, h, onMounted, watch } from 'vue';
import { NBadge } from 'naive-ui'
import { ref, h, onMounted, watch, computed } from 'vue';
import { NBadge, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from '../../store'
@@ -9,13 +9,12 @@ import { NButton, NMenu } from 'naive-ui';
import { MenuFilled } from '@vicons/material'
const {
localeCache, adminAuth, showAdminAuth, loading,
adminTab, adminMailTabAddress, adminSendBoxTabAddress
loading, adminTab, openSettings,
adminMailTabAddress, adminSendBoxTabAddress
} = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
name: 'Name',
@@ -23,18 +22,37 @@ const { t } = useI18n({
updated_at: 'Update At',
mail_count: 'Mail Count',
send_count: 'Send Count',
showPass: 'Show Passwrod',
password: 'Password',
passwordTip: 'Please copy the password and you can use it to login to your email account.',
source_meta: 'Source',
showCredential: 'Show Mail Address Credential',
addressCredential: 'Mail Address Credential',
addressCredentialTip: 'Please copy the Mail Address Credential and you can use it to login to your email account.',
delete: 'Delete',
deleteTip: 'Are you sure to delete this email?',
delteAccount: 'Delete Account',
deleteAccount: 'Delete Account',
viewMails: 'View Mails',
viewSendBox: 'View SendBox',
itemCount: 'itemCount',
query: 'Query',
addressQueryTip: 'Leave blank to query all addresses',
actions: 'Actions'
clearInbox: 'Clear Inbox',
clearSentItems: 'Clear Sent Items',
clearInboxTip: 'Are you sure to clear inbox for this email?',
clearSentItemsTip: 'Are you sure to clear sent items for this email?',
actions: 'Actions',
success: 'Success',
resetPassword: 'Reset Password',
newPassword: 'New Password',
passwordResetSuccess: 'Password reset successfully',
selectAll: 'Select All of This Page',
unselectAll: 'Unselect All',
pleaseSelectAddress: 'Please select address',
selectedItems: 'Selected',
multiDelete: 'Multi Delete',
multiDeleteTip: 'Are you sure to delete selected addresses?',
multiClearInbox: 'Multi Clear Inbox',
multiClearInboxTip: 'Are you sure to clear inbox for selected addresses?',
multiClearSentItems: 'Multi Clear Sent Items',
multiClearSentItemsTip: 'Are you sure to clear sent items for selected addresses?',
},
zh: {
name: '名称',
@@ -42,25 +60,58 @@ const { t } = useI18n({
updated_at: '更新时间',
mail_count: '邮件数量',
send_count: '发送数量',
showPass: '显示密码',
password: '密码',
passwordTip: '请复制密码,你可以使用它登录你的邮箱。',
source_meta: '来源',
showCredential: '查看邮箱地址凭证',
addressCredential: '邮箱地址凭证',
addressCredentialTip: '请复制邮箱地址凭证,你可以使用它登录你的邮箱。',
delete: '删除',
deleteTip: '确定要删除这个邮箱吗?',
delteAccount: '删除邮箱',
deleteAccount: '删除邮箱',
viewMails: '查看邮件',
viewSendBox: '查看发件箱',
itemCount: '总数',
query: '查询',
addressQueryTip: '留空查询所有地址',
clearInbox: '清空收件箱',
clearSentItems: '清空发件箱',
clearInboxTip: '确定要清空这个邮箱的收件箱吗?',
clearSentItemsTip: '确定要清空这个邮箱的发件箱吗?',
actions: '操作',
success: '成功',
resetPassword: '重置密码',
newPassword: '新密码',
passwordResetSuccess: '密码重置成功',
selectAll: '全选本页',
unselectAll: '取消全选',
pleaseSelectAddress: '请选择地址',
selectedItems: '已选择',
multiDelete: '批量删除',
multiDeleteTip: '确定要删除选中的邮箱吗?',
multiClearInbox: '批量清空收件箱',
multiClearInboxTip: '确定要清空选中邮箱的收件箱吗?',
multiClearSentItems: '批量清空发件箱',
multiClearSentItemsTip: '确定要清空选中邮箱的发件箱吗?',
}
}
});
const showEmailPassword = ref(false)
const curEmailPassword = ref("")
const showEmailCredential = ref(false)
const curEmailCredential = ref("")
const curDeleteAddressId = ref(0);
const curClearInboxAddressId = ref(0);
const curClearSentItemsAddressId = ref(0);
const showResetPassword = ref(false);
const curResetPasswordAddressId = ref(0);
const newPassword = ref('');
// Multi-action mode state
const checkedRowKeys = ref([]);
const showMultiActionModal = ref(false);
const multiActionProgress = ref({ percentage: 0, tip: '0/0' });
const multiActionTitle = ref('');
const selectedCount = computed(() => checkedRowKeys.value.length);
const showMultiActionBar = computed(() => checkedRowKeys.value.length > 0);
const addressQuery = ref("")
@@ -68,32 +119,172 @@ const data = ref([])
const count = ref(0)
const page = ref(1)
const pageSize = ref(20)
const showDelteAccount = ref(false)
const showDeleteAccount = ref(false)
const showClearInbox = ref(false)
const showClearSentItems = ref(false)
const showPassword = async (id) => {
const showCredential = async (id) => {
try {
curEmailPassword.value = await api.adminShowPassword(id)
showEmailPassword.value = true
curEmailCredential.value = await api.adminShowAddressCredential(id)
showEmailCredential.value = true
} catch (error) {
message.error(error.message || "error");
showEmailPassword.value = false
curEmailPassword.value = ""
showEmailCredential.value = false
curEmailCredential.value = ""
}
}
const deleteEmail = async () => {
try {
await api.adminDeleteAddress(curDeleteAddressId.value)
message.success("success");
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
showDelteAccount.value = false
} finally {
showDeleteAccount.value = false
}
}
const clearInbox = async () => {
try {
await api.fetch(`/admin/clear_inbox/${curClearInboxAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearInbox.value = false
}
}
const clearSentItems = async () => {
try {
await api.fetch(`/admin/clear_sent_items/${curClearSentItemsAddressId.value}`, {
method: 'DELETE'
});
message.success(t("success"));
await fetchData()
} catch (error) {
message.error(error.message || "error");
} finally {
showClearSentItems.value = false
}
}
const resetPassword = async () => {
try {
await api.fetch(`/admin/address/${curResetPasswordAddressId.value}/reset_password`, {
method: 'POST',
body: JSON.stringify({
password: newPassword.value
})
});
message.success(t("passwordResetSuccess"));
newPassword.value = '';
showResetPassword.value = false;
} catch (error) {
message.error(error.message || "error");
}
}
// Multi-action mode functions
const multiActionSelectAll = () => {
checkedRowKeys.value = data.value.map(item => item.id);
}
const multiActionUnselectAll = () => {
checkedRowKeys.value = [];
}
// 通用批量操作函数
const executeBatchOperation = async ({
shouldSkip = () => false,
apiCall,
title,
operationName = 'operation'
}) => {
try {
loading.value = true;
const selectedAddresses = data.value.filter((item) =>
checkedRowKeys.value.includes(item.id)
);
if (selectedAddresses.length === 0) {
message.error(t('pleaseSelectAddress'));
return;
}
const failedIds = [];
const totalCount = selectedAddresses.length;
multiActionProgress.value = {
percentage: 0,
tip: `0/${totalCount}`
};
multiActionTitle.value = title;
showMultiActionModal.value = true;
for (const [index, address] of selectedAddresses.entries()) {
try {
if (!shouldSkip(address)) {
await apiCall(address.id);
}
} catch (error) {
console.error(`${operationName} failed for address ${address.id}:`, error);
failedIds.push(address.id);
}
multiActionProgress.value = {
percentage: Math.floor((index + 1) / totalCount * 100),
tip: `${index + 1}/${totalCount}`
};
}
await fetchData();
checkedRowKeys.value = failedIds;
message.success(t("success"));
} catch (error) {
message.error(error.message || "error");
} finally {
loading.value = false;
}
}
const multiActionDeleteAccounts = async () => {
await executeBatchOperation({
apiCall: (id) => api.adminDeleteAddress(id),
title: t('multiDelete') + ' ' + t('success'),
operationName: 'Delete'
});
}
const multiActionClearInbox = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.mail_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_inbox/${id}`, {
method: 'DELETE'
}),
title: t('multiClearInbox') + ' ' + t('success'),
operationName: 'ClearInbox'
});
}
const multiActionClearSentItems = async () => {
await executeBatchOperation({
shouldSkip: (address) => address.send_count <= 0,
apiCall: (id) => api.fetch(`/admin/clear_sent_items/${id}`, {
method: 'DELETE'
}),
title: t('multiClearSentItems') + ' ' + t('success'),
operationName: 'ClearSentItems'
});
}
const fetchData = async () => {
try {
addressQuery.value = addressQuery.value.trim()
const { results, count: addressCount } = await api.fetch(
`/admin/address`
+ `?limit=${pageSize.value}`
@@ -105,12 +296,15 @@ const fetchData = async () => {
count.value = addressCount;
}
} catch (error) {
console.log(error)
console.error(error);
message.error(error.message || "error");
}
}
const columns = [
{
type: 'selection'
},
{
title: "ID",
key: "id"
@@ -127,6 +321,24 @@ const columns = [
title: t('updated_at'),
key: "updated_at"
},
{
title: t('source_meta'),
key: "source_meta",
render(row) {
const val = row.source_meta;
if (!val) return '';
const ipv4Regex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
const ipv6Regex = /^[0-9a-fA-F:]+$/;
if (ipv4Regex.test(val) || (val.includes(':') && ipv6Regex.test(val) && !val.startsWith('tg:'))) {
return h('a', {
href: `https://ip.im/${val}`,
target: '_blank',
rel: 'noopener noreferrer'
}, val);
}
return val;
}
},
{
title: t('mail_count'),
key: "mail_count",
@@ -196,9 +408,9 @@ const columns = [
label: () => h(NButton,
{
text: true,
onClick: () => showPassword(row.id)
onClick: () => showCredential(row.id)
},
{ default: () => t('showPass') }
{ default: () => t('showCredential') }
),
},
{
@@ -211,7 +423,8 @@ const columns = [
}
},
{ default: () => t('viewMails') }
)
),
show: row.mail_count > 0
},
{
label: () => h(NButton,
@@ -223,7 +436,47 @@ const columns = [
}
},
{ default: () => t('viewSendBox') }
)
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearInboxAddressId.value = row.id;
showClearInbox.value = true;
}
},
{ default: () => t('clearInbox') }
),
show: row.mail_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curClearSentItemsAddressId.value = row.id;
showClearSentItems.value = true;
}
},
{ default: () => t('clearSentItems') }
),
show: row.send_count > 0
},
{
label: () => h(NButton,
{
text: true,
onClick: () => {
curResetPasswordAddressId.value = row.id;
showResetPassword.value = true;
}
},
{ default: () => t('resetPassword') }
),
show: openSettings.value?.enableAddressPassword
},
{
label: () => h(NButton,
@@ -231,7 +484,7 @@ const columns = [
text: true,
onClick: () => {
curDeleteAddressId.value = row.id;
showDelteAccount.value = true;
showDeleteAccount.value = true;
}
},
{ default: () => t('delete') }
@@ -251,52 +504,121 @@ watch([page, pageSize], async () => {
})
onMounted(async () => {
if (!adminAuth.value) {
showAdminAuth.value = true;
return;
}
await fetchData()
})
</script>
<template>
<div>
<n-modal v-model:show="showEmailPassword" preset="dialog" title="Dialog">
<div style="margin-top: 10px;">
<n-modal v-model:show="showEmailCredential" preset="dialog" title="Dialog">
<template #header>
<div>{{ t("password") }}</div>
<div>{{ t("addressCredential") }}</div>
</template>
<span>
<p>{{ t("passwordTip") }}</p>
<p>{{ t("addressCredentialTip") }}</p>
</span>
<n-card>
<b>{{ curEmailPassword }}</b>
<n-card :bordered="false" embedded>
<b>{{ curEmailCredential }}</b>
</n-card>
<template #action>
</template>
</n-modal>
<n-modal v-model:show="showDelteAccount" preset="dialog" :title="t('delteAccount')">
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
<p>{{ t('deleteTip') }}</p>
<template #action>
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
{{ t('delteAccount') }}
{{ t('deleteAccount') }}
</n-button>
</template>
</n-modal>
<n-input-group>
<n-input v-model:value="addressQuery" clearable :placeholder="t('addressQueryTip')" />
<n-button @click="fetchData" type="primary" ghost>
<n-modal v-model:show="showClearInbox" preset="dialog" :title="t('clearInbox')">
<p>{{ t('clearInboxTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearInbox" size="small" tertiary type="error">
{{ t('clearInbox') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showClearSentItems" preset="dialog" :title="t('clearSentItems')">
<p>{{ t('clearSentItemsTip') }}</p>
<template #action>
<n-button :loading="loading" @click="clearSentItems" size="small" tertiary type="error">
{{ t('clearSentItems') }}
</n-button>
</template>
</n-modal>
<n-modal v-model:show="showResetPassword" preset="dialog" :title="t('resetPassword')">
<n-form-item :label="t('newPassword')">
<n-input v-model:value="newPassword" type="password" placeholder="" show-password-on="click" />
</n-form-item>
<template #action>
<n-button :loading="loading" @click="resetPassword" size="small" tertiary type="info">
{{ t('resetPassword') }}
</n-button>
</template>
</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>
{{ t('query') }}
</n-button>
</n-input-group>
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
<n-space v-if="showMultiActionBar" style="margin-bottom: 10px;">
<n-button @click="multiActionSelectAll" tertiary>
{{ t('selectAll') }}
</n-button>
<n-button @click="multiActionUnselectAll" tertiary>
{{ t('unselectAll') }}
</n-button>
<n-popconfirm @positive-click="multiActionDeleteAccounts">
<template #trigger>
<n-button tertiary type="error">{{ t('multiDelete') }}</n-button>
</template>
</n-pagination>
{{ t('multiDeleteTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearInbox">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearInbox') }}</n-button>
</template>
{{ t('multiClearInboxTip') }}
</n-popconfirm>
<n-popconfirm @positive-click="multiActionClearSentItems">
<template #trigger>
<n-button tertiary type="warning">{{ t('multiClearSentItems') }}</n-button>
</template>
{{ t('multiClearSentItemsTip') }}
</n-popconfirm>
<n-tag type="info">
{{ t('selectedItems') }}: {{ selectedCount }}
</n-tag>
</n-space>
<div style="overflow: auto;">
<div style="display: inline-block;">
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
:page-sizes="[20, 50, 100]" show-size-picker>
<template #prefix="{ itemCount }">
{{ t('itemCount') }}: {{ itemCount }}
</template>
</n-pagination>
</div>
<n-data-table v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" :bordered="false"
:row-key="row => row.id" embedded />
</div>
<n-data-table :columns="columns" :data="data" :bordered="false" />
<!-- Multi-action progress modal -->
<n-modal v-model:show="showMultiActionModal" preset="dialog" :title="multiActionTitle" negative-text="OK">
<n-space justify="center">
<n-progress type="circle" status="info" :percentage="multiActionProgress.percentage">
<span style="text-align: center">
{{ multiActionProgress.tip }}
</span>
</n-progress>
</n-space>
</n-modal>
</div>
</template>
@@ -305,4 +627,8 @@ onMounted(async () => {
margin-top: 10px;
margin-bottom: 10px;
}
.n-data-table {
min-width: 1000px;
}
</style>

View File

@@ -1,37 +1,264 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onMounted, ref, h } from 'vue';
import { useI18n } from 'vue-i18n'
import { NButton, NPopconfirm, NInput, NSelect, NRadioGroup, NRadio } from 'naive-ui'
import { useGlobalState } from '../../store'
import { api } from '../../api'
const { localeCache, loading } = useGlobalState()
const { loading, openSettings } = useGlobalState()
const message = useMessage()
const { t } = useI18n({
locale: localeCache.value || 'zh',
messages: {
en: {
tip: 'You can manually input the following multiple select input and enter',
manualInputPrompt: 'Type and press Enter to add',
save: 'Save',
successTip: 'Save Success',
address_block_list: 'Address Block Keywords for Users(Admin can skip)',
address_block_list_placeholder: 'Please enter the keywords you want to block',
send_address_block_list: 'Address Block Keywords for send email',
noLimitSendAddressList: 'No Balance Limit Send Address List',
verified_address_list: 'Verified Address List(Can send email by cf internal api)',
fromBlockList: 'Block Keywords for receive email',
block_receive_unknow_address_email: 'Block receive unknow address email',
email_forwarding_config: 'Email Forwarding Configuration',
domain_list: 'Domain List (Optional)',
forward_address: 'Forward Address',
actions: 'Actions',
select_domain: 'Select Domain',
forward_placeholder: 'forward@example.com',
delete_rule: 'Delete',
delete_rule_confirm: 'Are you sure you want to delete this rule?',
delete_success: 'Delete Success',
forwarding_rule_warning: 'Each rule will run independently. Forward address needs to be a verified address.',
add: 'Add',
cancel: 'Cancel',
config: 'Config',
source_patterns: 'Source Address Regex (Optional)',
source_patterns_placeholder: 'e.g. gmail.com',
source_match_mode: 'Match Mode',
match_any: 'Any',
match_all: 'All',
source_patterns_tip: 'Domain list filters by recipient address, source regex filters by sender address. Both conditions must match for forwarding (AND logic). Leave either empty to skip that filter.',
regex_too_long: 'Regex pattern too long (max 200 characters)',
regex_invalid: 'Invalid regex pattern',
forward_address_required: 'Forward address is required',
rule_index: 'Rule',
},
zh: {
tip: '您可以手动输入以下多选输入框, 回车增加',
manualInputPrompt: '输入后按回车键添加',
save: '保存',
successTip: '保存成功',
address_block_list: '用户地址屏蔽关键词(管理员可跳过检查)',
address_block_list: '邮件地址屏蔽关键词(管理员可跳过检查)',
address_block_list_placeholder: '请输入您想要屏蔽的关键词',
send_address_block_list: '发送邮件地址屏蔽关键词',
noLimitSendAddressList: '无余额限制发送地址列表',
verified_address_list: '已验证地址列表(可通过 cf 内部 api 发送邮件)',
fromBlockList: '接收邮件地址屏蔽关键词',
block_receive_unknow_address_email: '禁止接收未知地址邮件',
email_forwarding_config: '邮件转发配置',
domain_list: '域名列表(可选)',
forward_address: '转发地址',
actions: '操作',
select_domain: '选择域名',
forward_placeholder: 'forward@example.com',
delete_rule: '删除',
delete_rule_confirm: '确定要删除这条规则吗?',
delete_success: '删除成功',
forwarding_rule_warning: '每条规则独立运行,转发地址需要为已验证的地址。',
add: '添加',
cancel: '取消',
config: '配置',
source_patterns: '来源地址正则(可选)',
source_patterns_placeholder: '例如: gmail.com',
source_match_mode: '匹配模式',
match_any: '任一',
match_all: '全部',
source_patterns_tip: '域名列表按收件地址过滤来源正则按发件地址过滤两者均为可选。同时配置时需同时满足AND 逻辑),留空则跳过该条件。',
regex_too_long: '正则表达式过长最大200字符',
regex_invalid: '无效的正则表达式',
forward_address_required: '转发地址不能为空',
rule_index: '规则',
}
}
});
const addressBlockList = ref([])
const sendAddressBlockList = ref([])
const noLimitSendAddressList = ref([])
const verifiedAddressList = ref([])
const fromBlockList = ref([])
const emailRuleSettings = ref({
blockReceiveUnknowAddressEmail: false,
emailForwardingList: []
})
const showEmailForwardingModal = ref(false)
const emailForwardingList = ref([])
const emailForwardingColumns = [
{
title: t('domain_list'),
key: 'domains',
render: (row, index) => {
return h(NSelect, {
value: Array.isArray(row.domains) ? row.domains : [],
onUpdateValue: (val) => {
emailForwardingList.value[index].domains = val
},
options: openSettings.value?.domains || [],
multiple: true,
filterable: true,
tag: true,
placeholder: t('select_domain')
})
}
},
{
title: t('source_patterns'),
key: 'sourcePatterns',
render: (row, index) => {
return h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' }, [
h(NSelect, {
value: Array.isArray(row.sourcePatterns) ? row.sourcePatterns : [],
onUpdateValue: (val) => {
emailForwardingList.value[index].sourcePatterns = val
},
multiple: true,
filterable: true,
tag: true,
placeholder: t('source_patterns_placeholder')
}, {
empty: () => h('span', { style: 'color: #999; font-size: 12px;' }, t('manualInputPrompt'))
}),
h(NRadioGroup, {
value: row.sourceMatchMode || 'any',
onUpdateValue: (val) => {
emailForwardingList.value[index].sourceMatchMode = val
},
size: 'small',
style: 'margin-top: 4px;'
}, {
default: () => [
h(NRadio, { value: 'any' }, { default: () => t('match_any') }),
h(NRadio, { value: 'all' }, { default: () => t('match_all') })
]
})
])
}
},
{
title: t('forward_address'),
key: 'forward',
render: (row, index) => {
return h(NInput, {
value: row.forward,
onUpdateValue: (val) => {
emailForwardingList.value[index].forward = val
},
placeholder: 'forward@example.com'
})
}
},
{
title: t('actions'),
key: 'actions',
render: (row, index) => {
return h('div', { style: 'display: flex; gap: 8px;' }, [
h(NPopconfirm, {
onPositiveClick: () => {
emailForwardingList.value = emailForwardingList.value.filter((_, i) => i !== index)
message.success(t('delete_success'))
}
}, {
default: () => t('delete_rule_confirm'),
trigger: () => h(NButton, {
size: 'small',
type: 'error'
}, { default: () => t('delete_rule') })
})
])
}
}
]
const openEmailForwardingModal = () => {
// 从 emailRuleSettings 转换出列表数据
emailForwardingList.value = emailRuleSettings.value.emailForwardingList ?
[...emailRuleSettings.value.emailForwardingList] : []
showEmailForwardingModal.value = true
}
const addNewEmailForwardingItem = () => {
emailForwardingList.value = [
...emailForwardingList.value,
{
domains: [],
forward: '',
sourcePatterns: [],
sourceMatchMode: 'any'
}
]
}
const MAX_REGEX_LENGTH = 200
const validateForwardingRules = () => {
for (let i = 0; i < emailForwardingList.value.length; i++) {
const rule = emailForwardingList.value[i]
// 验证转发地址
if (!rule.forward || rule.forward.trim() === '') {
message.error(`${t('forward_address_required')} (${t('rule_index')} ${i + 1})`)
return false
}
// 验证正则表达式
if (rule.sourcePatterns && rule.sourcePatterns.length > 0) {
for (const pattern of rule.sourcePatterns) {
// 检查长度
if (pattern.length > MAX_REGEX_LENGTH) {
message.error(`${t('regex_too_long')}: ${pattern.substring(0, 30)}...`)
return false
}
// 检查正则有效性
try {
new RegExp(pattern, 'i')
} catch (e) {
message.error(`${t('regex_invalid')}: ${pattern}`)
return false
}
}
}
}
return true
}
const saveEmailForwardingConfig = () => {
if (!validateForwardingRules()) {
return
}
emailRuleSettings.value.emailForwardingList = [...emailForwardingList.value]
showEmailForwardingModal.value = false
}
const fetchData = async () => {
try {
const res = await api.fetch(`/admin/account_settings`)
addressBlockList.value = res.blockList || []
sendAddressBlockList.value = res.sendBlockList || []
verifiedAddressList.value = res.verifiedAddressList || []
fromBlockList.value = res.fromBlockList || []
noLimitSendAddressList.value = res.noLimitSendAddressList || []
emailRuleSettings.value = {
blockReceiveUnknowAddressEmail: res.emailRuleSettings?.blockReceiveUnknowAddressEmail || false,
emailForwardingList: res.emailRuleSettings?.emailForwardingList || []
}
} catch (error) {
message.error(error.message || "error");
}
@@ -42,7 +269,12 @@ const save = async () => {
await api.fetch(`/admin/account_settings`, {
method: 'POST',
body: JSON.stringify({
blockList: addressBlockList.value || []
blockList: addressBlockList.value || [],
sendBlockList: sendAddressBlockList.value || [],
verifiedAddressList: verifiedAddressList.value || [],
fromBlockList: fromBlockList.value || [],
noLimitSendAddressList: noLimitSendAddressList.value || [],
emailRuleSettings: emailRuleSettings.value,
})
})
message.success(t('successTip'))
@@ -59,16 +291,91 @@ onMounted(async () => {
<template>
<div class="center">
<n-card style="max-width: 600px;">
<n-card :bordered="false" embedded style="max-width: 600px;">
<n-alert :show-icon="false" :bordered="false" type="warning" style="margin-bottom: 10px;">
<span>{{ t("tip") }}</span>
</n-alert>
<n-flex justify="end">
<n-button @click="save" type="primary" :loading="loading">
{{ t('save') }}
</n-button>
</n-flex>
<n-form-item-row :label="t('address_block_list')">
<n-select v-model:value="addressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')" />
:placeholder="t('address_block_list_placeholder')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('send_address_block_list')">
<n-select v-model:value="sendAddressBlockList" filterable multiple tag
:placeholder="t('address_block_list_placeholder')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('noLimitSendAddressList')">
<n-select v-model:value="noLimitSendAddressList" filterable multiple tag
:placeholder="t('noLimitSendAddressList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('verified_address_list')">
<n-select v-model:value="verifiedAddressList" filterable multiple tag
:placeholder="t('verified_address_list')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<n-form-item-row :label="t('fromBlockList')">
<n-select v-model:value="fromBlockList" filterable multiple tag :placeholder="t('fromBlockList')">
<template #empty>
<n-text depth="3">
{{ t('manualInputPrompt') }}
</n-text>
</template>
</n-select>
</n-form-item-row>
<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('email_forwarding_config')">
<n-button @click="openEmailForwardingModal">{{ t('config') }}</n-button>
</n-form-item-row>
<n-button @click="save" type="primary" block :loading="loading">
{{ t('save') }}
</n-button>
</n-card>
</div>
<!-- 邮件转发配置弹窗 -->
<n-modal v-model:show="showEmailForwardingModal" preset="card" :title="t('email_forwarding_config')"
style="max-width: 1000px;">
<n-space vertical>
<n-alert :show-icon="false" :bordered="false" type="warning">
<span>{{ t('forwarding_rule_warning') }}</span>
<br />
<span>{{ t('source_patterns_tip') }}</span>
</n-alert>
<n-space justify="end">
<n-button @click="addNewEmailForwardingItem">{{ t('add') }}</n-button>
</n-space>
<n-data-table :columns="emailForwardingColumns" :data="emailForwardingList" :bordered="false" striped />
<n-space justify="end">
<n-button @click="saveEmailForwardingConfig" type="primary">{{ t('save') }}</n-button>
</n-space>
</n-space>
</n-modal>
</template>
<style scoped>

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